diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..792e93cda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/node_modules +.git +.reference +.turbo +**/dist +**/.executor* +**/coverage +**/.next +**/*.log +.claude diff --git a/.gitignore b/.gitignore index 3d826ac00..b9b3aed73 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,12 @@ personal-notes/ *.har.executor executor.har .executor/ -apps/local/.executor-dev/ +# Per-app dev runtime data dirs (local SQLite DB + generated secret.key) — never +# commit. Glob covers .executor-dev (cloud/cloudflare dev) AND .executor-selfhost +# (the self-host data dir) and any future .executor- dir. +.executor-*/ +# Belt-and-suspenders: never commit a generated session/at-rest key, wherever it lands. +secret.key # desktop app build artifacts apps/desktop/resources/ @@ -53,6 +58,12 @@ apps/desktop/resources/ # cloud local dev database .pglite apps/cloud/.dev-db/ +apps/cloud/.e2e-db/ + +# playwright e2e artifacts +test-results/ +playwright-report/ +.last-run.json .claude/ .nitro/ .output/ diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 8ad145a68..f8897c394 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -67,6 +67,16 @@ "executor/no-unknown-error-message": "off", }, }, + { + // Playwright e2e specs drive a real browser: they stringify browser-side + // errors and use console/promise APIs that the Effect-domain rules forbid. + "files": ["apps/cloud/e2e/**/*.{ts,tsx}"], + "rules": { + "executor/no-promise-catch": "off", + "executor/no-try-catch-or-throw": "off", + "executor/no-unknown-error-message": "off", + }, + }, { "files": ["apps/marketing/src/**/*.astro"], "rules": { diff --git a/apps/cli/src/build.ts b/apps/cli/src/build.ts index 0fca65ef9..f1c7fd511 100644 --- a/apps/cli/src/build.ts +++ b/apps/cli/src/build.ts @@ -161,6 +161,45 @@ const resolveKeyringNative = (t: Target): string | null => { } }; +/** + * Resolve the platform-specific `@libsql/` native binding for a target. + * + * The local server's SQLite driver (libSQL) loads its `.node` via a dynamic + * `require('@libsql/')`, which `bun build --compile` can't bundle into + * bunfs (same limitation as keyring). We copy the right `.node` next to the + * executor as `libsql.node`; main.ts redirects the bare require to it. + */ +const LIBSQL_NATIVE_VERSION = "0.5.29"; +const resolveLibsqlNative = (t: Target): string | null => { + const platformMap: Record = { + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + // The compiled binary runs on Bun, which libSQL's loader treats as glibc + // (its musl->gnu workaround), so non-musl linux targets need the -gnu binding. + "linux-arm64": "linux-arm64-gnu", + "linux-x64": "linux-x64-gnu", + "linux-arm64-musl": "linux-arm64-musl", + "linux-x64-musl": "linux-x64-musl", + "win32-arm64": "win32-arm64-msvc", + "win32-x64": "win32-x64-msvc", + }; + const key = [t.os, t.arch, t.abi].filter(Boolean).join("-"); + const target = platformMap[key]; + if (!target) return null; + const pkg = `@libsql/${target}`; + try { + const req = createRequire(join(repoRoot, "apps/local", "package.json")); + const pkgJson = req.resolve(`${pkg}/package.json`); + return join(dirname(pkgJson), "index.node"); + } catch { + const bunPath = join( + repoRoot, + `node_modules/.bun/${pkg.replace("/", "+")}@${LIBSQL_NATIVE_VERSION}/node_modules/${pkg}/index.node`, + ); + return existsSync(bunPath) ? bunPath : null; + } +}; + // --------------------------------------------------------------------------- // Build mode // --------------------------------------------------------------------------- @@ -251,7 +290,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { const meta = await readMetadata(); const binaries: Record = {}; const embeddedWebUIPath = join(cliRoot, "src/embedded-web-ui.gen.ts"); - const embeddedMigrationsPath = join(webRoot, "src/server/embedded-migrations.gen.ts"); + const embeddedMigrationsPath = join(webRoot, "src/db/embedded-migrations.gen.ts"); await rm(distDir, { recursive: true, force: true }); @@ -310,6 +349,13 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { await cp(keyringNative, join(binDir, "keyring.node")); } + // Copy the libSQL native binding next to executor — same bunfs limitation + // as keyring; main.ts redirects `require('@libsql/')` to it. + const libsqlNative = resolveLibsqlNative(target); + if (libsqlNative && existsSync(libsqlNative)) { + await cp(libsqlNative, join(binDir, "libsql.node")); + } + // Smoke test on current platform if (isCurrentPlatform(target)) { const bin = join(binDir, binaryName(target)); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index bbc3187f8..cd27c3f90 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -1,3 +1,7 @@ +// MUST be first: publishes the colocated libSQL/keyring native `.node` paths +// before any import (e.g. `@executor-js/local` → libSQL) eagerly loads them. +import "./native-bindings"; + import { randomUUID } from "node:crypto"; import { existsSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -8,24 +12,6 @@ if (process.env.PATH && !process.env.PATH.includes(execDir)) { process.env.PATH = `${execDir}:${process.env.PATH}`; } -// Point the keychain plugin at the colocated @napi-rs/keyring binding. -// bun --compile doesn't include .node files in bunfs, so the loader's -// normal `require('@napi-rs/keyring--')` walk fails inside the -// binary. We can't use NAPI_RS_NATIVE_LIBRARY_PATH because @napi-rs/keyring -// 1.2.0 has a bug where the env-var branch assigns to a local variable that -// gets overwritten before the binding is returned. build.ts copies the -// platform .node next to the executor; the keychain plugin reads this var -// and loads the file directly via createRequire, bypassing the broken -// loader. -const keyringNodeOnDisk = join(execDir, "keyring.node"); -if ( - typeof Bun !== "undefined" && - !process.env.EXECUTOR_KEYRING_NATIVE_PATH && - (await Bun.file(keyringNodeOnDisk).exists()) -) { - process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; -} - // Pre-load QuickJS WASM for compiled binaries — must run before server imports const wasmOnDisk = join(execDir, "emscripten-module.wasm"); if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { diff --git a/apps/cli/src/native-bindings.ts b/apps/cli/src/native-bindings.ts new file mode 100644 index 000000000..51cb04a7e --- /dev/null +++ b/apps/cli/src/native-bindings.ts @@ -0,0 +1,45 @@ +// --------------------------------------------------------------------------- +// Native-binding bootstrap for the `bun build --compile` binary. +// +// `bun --compile` bundles JS into bunfs but does NOT include `.node` native +// addons, so a dynamic `require('@libsql/')` / keyring walk inside +// the binary fails. build.ts copies each platform's `.node` next to the +// executable (`libsql.node`, `keyring.node`); here we publish their on-disk +// paths via env vars the loaders read. +// +// This MUST be the FIRST import in main.ts. ES modules evaluate every import +// before the importer's own body, and libSQL resolves its native addon EAGERLY +// at module load (`const {...} = requireNative()` in `libsql/index.js`). So the +// env var has to be set as a side effect of an import that is ordered before +// the `@executor-js/local` → `@libsql/client` graph — setting it in main.ts's +// body would run too late, after libSQL had already tried (and failed) to load. +// --------------------------------------------------------------------------- + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const execDir = dirname(process.execPath); + +// libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the +// colocated binding directly, before its (in-bunfs, doomed) platform-package walk. +const libsqlNodeOnDisk = join(execDir, "libsql.node"); +if ( + typeof Bun !== "undefined" && + !process.env.EXECUTOR_LIBSQL_NATIVE_PATH && + existsSync(libsqlNodeOnDisk) +) { + process.env.EXECUTOR_LIBSQL_NATIVE_PATH = libsqlNodeOnDisk; +} + +// keyring: the keychain plugin reads EXECUTOR_KEYRING_NATIVE_PATH (lazily, but +// set here alongside libSQL so all native colocation lives in one place). We +// can't use NAPI_RS_NATIVE_LIBRARY_PATH — @napi-rs/keyring 1.2.0's env-var +// branch assigns to a local that gets overwritten before the binding returns. +const keyringNodeOnDisk = join(execDir, "keyring.node"); +if ( + typeof Bun !== "undefined" && + !process.env.EXECUTOR_KEYRING_NATIVE_PATH && + existsSync(keyringNodeOnDisk) +) { + process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; +} diff --git a/apps/cloud/e2e/client-entry-hydration.spec.ts b/apps/cloud/e2e/client-entry-hydration.spec.ts new file mode 100644 index 000000000..6833192e6 --- /dev/null +++ b/apps/cloud/e2e/client-entry-hydration.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Regression guard: the cloud SPA must HYDRATE in a real browser. +// +// Motivating failure — on launch the console showed: +// +// Uncaught (in promise) TypeError: Failed to fetch dynamically imported +// module: /@id/virtual:tanstack-start-client-entry +// TypeError: Cannot read properties of undefined (reading 'has') +// +// …with a swarm of `net::ERR_ABORTED` on in-flight module requests. +// +// Root cause: that is Vite's *cold-start dependency re-optimization reload*. The +// first load after the import graph changes makes Vite re-bundle a late-discovered +// dep and force a full page reload, which aborts the in-flight client-entry import. +// It self-heals on the next load (hydration then succeeds). So the warm-up +// navigation below deliberately absorbs that benign one-time reload; the MEASURED +// navigation must then come up clean. +// +// What this guards against is the *persistent* version: the client entry failing +// to load on a settled server, leaving the app permanently dead. That is invisible +// to a request-level test (every module serves a clean 200 to `curl`) — it only +// surfaces in a browser running the module graph. Hence Playwright, booted by +// playwright.config.ts's webServer against a stub-env Vite dev + throwaway PGlite. +// --------------------------------------------------------------------------- + +// Only Vite's own dev module-graph URLs — the client entry and everything it +// statically/dynamically imports. Deliberately excludes third-party scripts +// (e.g. analytics under /api/a/static) that have their own, unrelated lifecycle. +const isViteModuleRequest = (url: string) => + url.includes("/@id/") || url.includes("/@fs/") || url.includes("/node_modules/.vite/"); + +test("the client entry hydrates — the SPA mounts, no dynamic-import failure", async ({ page }) => { + // Warm-up: the first cold load may trigger Vite's one-time dep re-optimize + + // reload. Swallow it here so the measured pass below sees a settled server. + await page.goto("/", { waitUntil: "load" }); + await page.waitForTimeout(1500); + + const fatal: string[] = []; + const abortedModules: string[] = []; + + // A persistent hydration failure surfaces as an unhandled rejection ("Failed to + // fetch dynamically imported module") and/or a thrown TypeError; capture both. + await page.addInitScript(() => { + window.addEventListener("unhandledrejection", (event) => { + console.error(`UNHANDLED_REJECTION: ${String(event.reason)}`); + }); + }); + page.on("console", (message) => { + const text = message.text(); + if ( + /failed to fetch dynamically imported module/i.test(text) || + /tanstack-start-client-entry/i.test(text) || + /UNHANDLED_REJECTION/i.test(text) + ) { + fatal.push(`[console.${message.type()}] ${text}`); + } + }); + page.on("pageerror", (error) => fatal.push(`[pageerror] ${String(error)}`)); + page.on("requestfailed", (request) => { + const failure = request.failure()?.errorText ?? ""; + if (/ERR_ABORTED/i.test(failure) && isViteModuleRequest(request.url())) { + abortedModules.push(`${failure} ${request.url()}`); + } + }); + + // Measured pass against the now-settled server. + await page.goto("/", { waitUntil: "load" }); + await page.waitForTimeout(2500); + + // The SSR shell always carries the title; that alone does NOT prove hydration. + await expect(page).toHaveTitle(/Executor/i); + + // (1) No dynamic-import / hydration crash. + expect(fatal, `client-entry/hydration errors:\n${fatal.join("\n")}`).toEqual([]); + + // (2) No aborted module fetches — the signature of the client entry failing to + // load (a stuck re-optimize, a boundary leak, a broken transform). + expect( + abortedModules, + `module requests were aborted (client entry did not load cleanly):\n${abortedModules.join("\n")}`, + ).toEqual([]); + + // (3) The client runtime actually booted: TanStack Start/Router installs its + // router on `window` during hydration. This is true regardless of auth state + // (the stub session is unauthenticated, so there's little rendered text to + // assert on — but a mounted client always exposes the router). + const hydrated = await page.evaluate( + () => Reflect.has(window, "__TSR_ROUTER__") || Reflect.has(window, "__TSR__"), + ); + expect(hydrated, "TanStack Start router never mounted — the SPA did not hydrate").toBe(true); +}); diff --git a/apps/cloud/e2e/e2e-server.ts b/apps/cloud/e2e/e2e-server.ts new file mode 100644 index 000000000..823e394aa --- /dev/null +++ b/apps/cloud/e2e/e2e-server.ts @@ -0,0 +1,67 @@ +// --------------------------------------------------------------------------- +// Boots the cloud app's Vite dev server for the Playwright e2e suite — the SAME +// dev stack a developer runs (`bun run dev`), minus 1Password / real WorkOS. +// +// Everything here is a STUB: fake WorkOS creds, a fixed cookie/encryption key, +// and a throwaway PGlite on its own port (so it never collides with a running +// `bun dev`). That's deliberate — what the spec guards (the TanStack Start client +// entry hydrating) is a CLIENT-side module-graph concern that doesn't depend on +// any of these values, so the stub config is sufficient and the harness stays +// runnable in CI with no secrets. +// +// Used by `playwright.config.ts`'s `webServer`. Spawns the dev DB + Vite, wires +// their stdout through, and tears both down on exit. +// --------------------------------------------------------------------------- + +import { spawn, type ChildProcess } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const appDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const PORT = process.env.E2E_PORT ?? "4798"; +const DB_PORT = process.env.E2E_DB_PORT ?? "5435"; +const ORIGIN = `http://127.0.0.1:${PORT}`; + +const stubEnv: NodeJS.ProcessEnv = { + ...process.env, + // WorkOS — never contacted during the hydration path; just has to be present. + WORKOS_API_KEY: "sk_e2e_stub", + WORKOS_CLIENT_ID: "client_e2e_stub", + WORKOS_COOKIE_PASSWORD: "e2e_cookie_password_0123456789abcdef0123456789abcdef", + AUTUMN_SECRET_KEY: "am_e2e_stub", + // 32-byte hex at-rest key (only used lazily on secret writes, not on render). + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + // Direct connection to the throwaway PGlite (no Hyperdrive in dev). + DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${DB_PORT}/postgres`, + EXECUTOR_DIRECT_DATABASE_URL: "true", + CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", + VITE_PUBLIC_SITE_URL: ORIGIN, + MCP_AUTHKIT_DOMAIN: "https://example.com", + MCP_RESOURCE_ORIGIN: ORIGIN, + // Throwaway dev DB on its own port + dir so it never fights a running `bun dev`. + DEV_DB_PORT: DB_PORT, + DEV_DB_PATH: resolve(appDir, ".e2e-db"), +}; + +const children: ChildProcess[] = []; +const start = (cmd: string, args: string[]) => { + const child = spawn(cmd, args, { cwd: appDir, env: stubEnv, stdio: "inherit" }); + child.on("exit", (code) => { + // If either process dies, take the whole harness down so Playwright fails fast. + if (code !== 0 && code !== null) { + shutdown(code); + } + }); + children.push(child); +}; + +const shutdown = (code = 0) => { + for (const child of children) child.kill("SIGTERM"); + process.exit(code); +}; +process.on("SIGINT", () => shutdown(0)); +process.on("SIGTERM", () => shutdown(0)); + +start("bun", ["run", "scripts/dev-db.ts"]); +start("bunx", ["vite", "dev", "--port", PORT, "--strictPort", "--host", "127.0.0.1"]); diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 7e38211c7..941e7bfaa 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -8,13 +8,16 @@ import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-w // Single source of truth for the cloud app's plugin list. // // Consumed by: -// - FumaDB schema wiring (calls `plugins({})`) // - the host runtime (calls `plugins({ workosCredentials })` per request) +// - the build/UI tooling (the vite plugin calls `plugins()` no-arg, reads +// `plugin.packageName` only) // - the test harness (calls `plugins({ workosVaultClient })` per test) +// (NOT by schema generation — the executor table set is fixed and +// plugin-independent, see `collectTables()`.) // // `TDeps` is inferred directly from the factory parameter annotation — // no global `declare module "@executor-js/sdk"` augmentation. Each -// caller (runtime / schema wiring / tests) passes whatever subset of the deps +// caller (runtime / build tooling / tests) passes whatever subset of the deps // it has; all fields are optional so `plugins({})` keeps working. // // Cloud only ships plugins safe to run in a multi-tenant setting — no diff --git a/apps/cloud/package.json b/apps/cloud/package.json index fc0fa9200..3d927d01c 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -8,7 +8,7 @@ "dev:proxy": "portless proxy start --multiplex --shared-port --port 5394 || (portless proxy stop -p 5394 && portless proxy start --multiplex --shared-port --port 5394)", "dev:db": "bun run scripts/dev-db.ts", "dev:vite": "EXECUTOR_DIRECT_DATABASE_URL=true CLOUDFLARE_INCLUDE_PROCESS_ENV=true op run --env-file=.env.op -- portless --name executor-cloud vite dev", - "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts schema generate --config ./executor.config.ts --output ./src/services/executor-schema.ts --namespace executor_cloud --adapter drizzle --provider postgresql", + "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts schema generate --output ./src/db/executor-schema.ts --namespace executor_cloud --adapter drizzle --provider postgresql", "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio", "db:studio:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs studio", @@ -22,6 +22,7 @@ "test": "node ../../node_modules/vitest/vitest.mjs run && node ../../node_modules/vitest/vitest.mjs run --config vitest.node.config.ts", "test:watch": "node ../../node_modules/vitest/vitest.mjs", "test:node": "node ../../node_modules/vitest/vitest.mjs run --config vitest.node.config.ts", + "test:e2e": "playwright test", "typecheck:slow": "tsc --noEmit" }, "dependencies": { @@ -29,6 +30,7 @@ "@effect/atom-react": "catalog:", "@effect/opentelemetry": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/cloudflare": "workspace:*", "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -74,6 +76,7 @@ "@electric-sql/pglite": "^0.4.4", "@electric-sql/pglite-socket": "^0.1.4", "@executor-js/cli": "workspace:*", + "@playwright/test": "^1.60.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@types/react": "catalog:", @@ -82,6 +85,7 @@ "concurrently": "^9.2.1", "drizzle-kit": "catalog:", "jiti": "^2.6.1", + "playwright": "^1.60.0", "typescript": "catalog:", "vite": "catalog:", "vitest": "^4.1.5", diff --git a/apps/cloud/playwright.config.ts b/apps/cloud/playwright.config.ts new file mode 100644 index 000000000..ec7a55191 --- /dev/null +++ b/apps/cloud/playwright.config.ts @@ -0,0 +1,49 @@ +import { defineConfig, devices } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Playwright e2e for the cloud app. Boots the real Vite dev server (stub env, +// throwaway PGlite — see e2e/e2e-server.ts) and drives it in a real browser, so +// failures that only surface during client hydration (the TanStack Start client +// entry not loading) are caught. The Vitest suites can't see these — they exercise +// the HTTP handler, not the browser module graph. +// --------------------------------------------------------------------------- + +const PORT = 4798; +const BASE_URL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + // One dev server; keep it serial + non-parallel so the assertions are stable. + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: process.env.CI ? "github" : "list", + timeout: 60_000, + expect: { timeout: 15_000 }, + use: { + baseURL: BASE_URL, + headless: true, + ignoreHTTPSErrors: true, + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + // Drive the system Chrome by default (no Chromium download needed); CI sets + // PLAYWRIGHT_USE_CHROMIUM=1 to use the Playwright-managed browser instead. + use: process.env.PLAYWRIGHT_USE_CHROMIUM + ? { ...devices["Desktop Chrome"] } + : { ...devices["Desktop Chrome"], channel: "chrome" }, + }, + ], + webServer: { + command: "bun run e2e/e2e-server.ts", + url: BASE_URL, + timeout: 120_000, + reuseExistingServer: !process.env.CI, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/apps/cloud/scripts/dev-db.ts b/apps/cloud/scripts/dev-db.ts index 5da4cea86..39b01d66d 100644 --- a/apps/cloud/scripts/dev-db.ts +++ b/apps/cloud/scripts/dev-db.ts @@ -17,8 +17,12 @@ import { drizzle } from "drizzle-orm/pglite"; import { migrate } from "drizzle-orm/pglite/migrator"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const PORT = 5433; -const DB_PATH = resolve(__dirname, "../.dev-db"); +// Port + data dir default to the dev values but are env-overridable so a second +// throwaway instance (e.g. the Playwright e2e harness) can run alongside `bun dev`. +const PORT = Number(process.env.DEV_DB_PORT ?? 5433); +const DB_PATH = process.env.DEV_DB_PATH + ? resolve(process.env.DEV_DB_PATH) + : resolve(__dirname, "../.dev-db"); const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); // Reap any orphan dev-db from a previous `bun dev` that didn't shut down diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts new file mode 100644 index 000000000..94f434278 --- /dev/null +++ b/apps/cloud/src/account/account-api.ts @@ -0,0 +1,113 @@ +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; + +import { + AccountProvider, + makeAccountApiLayer, + requestScopedMiddleware, +} from "@executor-js/api/server"; + +import { ApiKeyService } from "../auth/api-keys"; +import { UserStoreService } from "../auth/context"; +import { sessionFromSealed, type Session } from "../auth/middleware"; +import { WorkOSClient } from "../auth/workos"; +import { AutumnService } from "../extensions/billing/service"; +import { DbService } from "../db/db"; +import { AccountCaller, workosAccountProvider } from "./workos-account-service"; + +// --------------------------------------------------------------------------- +// Cloud account API — the shared, provider-neutral `AccountHandlers` backed by +// the WorkOS `AccountProvider`, mounted at the same `/account/*` paths the +// shared React `AccountApiClient` hits. Identical UI to self-host; only the +// service implementation differs. +// +// The caller is resolved ONCE per request by this middleware — the SAME +// cookie-only credential `SessionAuthLive` accepts: `WorkOSClient +// .authenticateSealedSession` over the request's `wos-session` cookie. The +// resolved session (or `null`) is injected into the service as `AccountCaller`; +// the service no longer parses the cookie itself, so `/account/*` accepts +// exactly the same credential set as before (cookie session only — NOT api-key +// Bearer, which is the executor `/api/*` plane). This API still carries NO +// HttpApiMiddleware: auth is the single resolution path in this middleware. +// +// GOTCHA: an HttpApi handler's service requirement (`AccountProvider`) is NOT +// erased by plain `Layer.provide`/`provideMerge` on the builder layer — it +// leaks into the app layer's requirements and breaks the build. So `AccountProvider` +// is provided through a per-request router middleware (like `protected.ts`'s +// `ExecutionStackMiddleware`): long-lived services (`WorkOSClient` from the boot +// core; `AutumnService` from this account layer's own provide — billing is +// app-only and not on the neutral boot core) are pulled from context, while the +// per-request `UserStoreService` (postgres) comes from `rsLive` combined in, so +// the socket lives in the request fiber's scope. `rsLive` is a parameter so +// tests can swap a fake. +// --------------------------------------------------------------------------- + +// Builds the WorkOS `AccountProvider` per request, providing it to the handler. +// Long-lived `WorkOSClient | AutumnService` come from the surrounding context +// (Autumn provided by `makeAccountApiLive` for the seat-gate); the per-request +// `UserStoreService` is supplied by the combined `rsLive` layer. +// `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`. +const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()( + Effect.gen(function* () { + // Long-lived services only (built once at boot). `UserStoreService` and + // `DbService` are NOT grabbed here — they come per request from the combined + // `requestScopedMiddleware(rsLive)` layer, which folds them into this + // middleware's body context (so they drop out of `requires`). + const longLived = yield* Effect.context(); + const workos = yield* WorkOSClient; + return (httpEffect) => + Effect.gen(function* () { + // Resolve the caller ONCE off the request's `wos-session` cookie — the + // same credential `SessionAuthLive` accepts (`authenticateSealedSession` + // over the sealed-session cookie). `null` => no/invalid session, which + // the service maps to AccountUnauthorized (401). + const request = yield* HttpServerRequest.HttpServerRequest; + const cookieValue = request.cookies["wos-session"] ?? ""; + const resolved = yield* workos + .authenticateSealedSession(cookieValue) + .pipe(Effect.orElseSucceed(() => null)); + // The account API never re-sets the cookie, so the fallback sealed + // session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie). + const session: Session | null = resolved ? sessionFromSealed(resolved, "") : null; + + // Built inside the request body so the WorkOS account service closes + // over the per-request `UserStoreService` (postgres socket) supplied by + // the combined request-scoped layer. + const accountProvider = yield* Effect.provide( + AccountProvider.asEffect(), + workosAccountProvider.pipe( + Layer.provide(ApiKeyService.WorkOS), + Layer.provide(Layer.succeed(AccountCaller)({ session })), + ), + ); + return yield* Effect.provideService(httpEffect, AccountProvider, accountProvider); + }).pipe(Effect.provideContext(longLived)); + }), +); + +/** + * The cloud account-provider middleware fed to `ExecutorApp.make`'s + * `providers.account` slot: the per-request `AccountProvider`-providing + * middleware combined with `requestScopedMiddleware(rsLive)` (so the WorkOS + * account service closes over the per-request postgres socket). `AutumnService` + * (the seat-gate) stays a residual requirement, satisfied by the app `boot`. + */ +export const workosAccountMiddleware = (rsLive: Layer.Layer) => + AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; + +export const makeAccountApiLive = (rsLive: Layer.Layer) => { + // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it + // closes over the per-request postgres socket), so it can't be a self- + // contained `Layer` — it combines its own middleware with + // `requestScopedMiddleware(rsLive)` and passes that to the shared mount + // helper. Cloud serves the account API at root (no prefixed router), matching + // the rest of the cloud router. + // + // `AutumnService.Default` is provided HERE because the account provider's + // seat-gate (`reserveMemberSlot` / member-limits) reads it — one of the few + // app-only billing touchpoints. It is NOT on the neutral boot core. + const accountMiddleware = AccountProviderMiddleware.combine( + requestScopedMiddleware(rsLive), + ).layer; + return makeAccountApiLayer(accountMiddleware).pipe(Layer.provideMerge(AutumnService.Default)); +}; diff --git a/apps/cloud/src/org/member-limits.node.test.ts b/apps/cloud/src/account/member-limits.node.test.ts similarity index 97% rename from apps/cloud/src/org/member-limits.node.test.ts rename to apps/cloud/src/account/member-limits.node.test.ts index bc027bd17..b044ee5e7 100644 --- a/apps/cloud/src/org/member-limits.node.test.ts +++ b/apps/cloud/src/account/member-limits.node.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits"; +import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "../extensions/billing/plans"; describe("member limits", () => { it("uses an active or trialing subscription before older entries", () => { diff --git a/apps/cloud/src/auth/organization-limits.node.test.ts b/apps/cloud/src/account/organization-limits.node.test.ts similarity index 98% rename from apps/cloud/src/auth/organization-limits.node.test.ts rename to apps/cloud/src/account/organization-limits.node.test.ts index b80ead2a5..f957a314b 100644 --- a/apps/cloud/src/auth/organization-limits.node.test.ts +++ b/apps/cloud/src/account/organization-limits.node.test.ts @@ -5,7 +5,7 @@ import { hasPaidOrganizationSubscription, isOverFreeOrganizationLimit, shouldApplyFreeOrganizationLimit, -} from "./organization-limits"; +} from "../extensions/billing/plans"; describe("organization limits", () => { it("treats active and trialing paid org subscriptions as paid", () => { diff --git a/apps/cloud/src/account/workos-account-service.test.ts b/apps/cloud/src/account/workos-account-service.test.ts new file mode 100644 index 000000000..4b6e0c36f --- /dev/null +++ b/apps/cloud/src/account/workos-account-service.test.ts @@ -0,0 +1,192 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { AccountHttpApi } from "@executor-js/api"; +import { AccountHandlers } from "@executor-js/api/server"; + +import { ApiKeyService } from "../auth/api-keys"; +import { UserStoreService } from "../auth/context"; +import type { Session } from "../auth/middleware"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { AutumnService } from "../extensions/billing/service"; +import { AccountCaller, workosAccountProvider } from "./workos-account-service"; + +// --------------------------------------------------------------------------- +// Mounts the SHARED, provider-neutral AccountHandlers over the cloud WorkOS +// AccountProvider and drives the routes through a web handler, proving that +// `/account/me` (authenticated) and `/account/api-keys` return the neutral +// shapes. The caller is now resolved ONCE by the cookie-only session +// middleware (account-api.ts) and injected as `AccountCaller`; these tests +// drive the service with that resolved caller directly. The shared React +// `AccountApiClient` hits these exact paths. +// --------------------------------------------------------------------------- + +const authedSession: Session = { + accountId: "user_1", + email: "user@test.com", + name: "Test User", + avatarUrl: null, + organizationId: "org_1", + sealedSession: "sealed_session", + refreshedSession: null, +}; + +const orgLessSession: Session = { ...authedSession, organizationId: null }; + +const stubWorkOS = (overrides: Partial = {}) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + if (typeof prop === "string" && prop in overrides) { + return overrides[prop as keyof WorkOSClientService]; + } + return () => Effect.void; + }, + }), + ); + +// User store stub — `resolveOrganization` reads `getOrganization` first, so +// returning the mirrored org short-circuits the WorkOS fallback. +const stubUserStore = Layer.succeed(UserStoreService)({ + use: ((fn: (s: unknown) => Promise) => + Effect.promise(() => + fn({ + getOrganization: () => Promise.resolve({ id: "org_1", name: "Test Org" }), + upsertOrganization: (org: { id: string; name: string }) => Promise.resolve(org), + }), + )) as UserStoreService["Service"]["use"], +}); + +const stubApiKeys = Layer.succeed(ApiKeyService)({ + validate: () => Effect.succeed(null), + listUserKeys: () => + Effect.succeed([ + { + id: "key_1", + name: "Local CLI", + obfuscatedValue: "exk_…a1b2", + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-04-01T00:00:00Z", + lastUsedAt: null, + }, + ]), + createUserKey: () => + Effect.succeed({ + id: "key_2", + name: "New key", + obfuscatedValue: "exk_…c3d4", + createdAt: "2026-04-02T00:00:00Z", + updatedAt: "2026-04-02T00:00:00Z", + lastUsedAt: null, + value: "exk_secret_value", + }), + revokeUserKey: () => Effect.void, +} satisfies ApiKeyService["Service"]); + +const stubAutumn = Layer.succeed(AutumnService)({ + use: (() => Effect.succeed({ subscriptions: [] })) as AutumnService["Service"]["use"], + trackExecution: () => Effect.void, +} satisfies AutumnService["Service"]); + +const makeFetch = (caller: Session | null, workos: Partial = {}) => { + const serviceLive = workosAccountProvider.pipe( + Layer.provide(stubWorkOS(workos)), + Layer.provide(stubApiKeys), + Layer.provide(stubAutumn), + Layer.provide(stubUserStore), + Layer.provide(Layer.succeed(AccountCaller)({ session: caller })), + ); + const apiLayer = HttpApiBuilder.layer(AccountHttpApi).pipe( + Layer.provide(AccountHandlers), + Layer.provideMerge(serviceLive), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ); + const web = HttpRouter.toWebHandler(apiLayer, { disableLogger: true }); + return web.handler as (request: Request) => Promise; +}; + +// The service only reads `data[*].organizationId` + `data[*].status`, so stub +// the minimal membership-list shape matching that contract rather than the full +// WorkOS SDK types — same approach as `auth/handlers.node.test.ts`. +const stubMemberships = ( + data: ReadonlyArray<{ organizationId: string; status: string }>, +): WorkOSClientService["listUserMemberships"] => + // oxlint-disable-next-line executor/no-double-cast -- test stub: minimal contract shape, not the full SDK list type + (() => Effect.succeed({ data })) as unknown as WorkOSClientService["listUserMemberships"]; + +describe("Cloud Account API (neutral surface, WorkOS-backed)", () => { + it.effect("GET /account/me returns the neutral user + organization for an authed session", () => + Effect.gen(function* () { + const fetch = makeFetch(authedSession, { + listUserMemberships: stubMemberships([{ organizationId: "org_1", status: "active" }]), + }); + + const response = yield* Effect.promise(() => + fetch(new Request("http://test.local/account/me")), + ); + expect(response.status).toBe(200); + const body = yield* Effect.promise(() => response.json()); + expect(body).toEqual({ + user: { + id: "user_1", + email: "user@test.com", + name: "Test User", + avatarUrl: null, + }, + organization: { id: "org_1", name: "Test Org" }, + }); + }), + ); + + it.effect("GET /account/me returns 401 when there is no valid session", () => + Effect.gen(function* () { + const fetch = makeFetch(null); + + const response = yield* Effect.promise(() => + fetch(new Request("http://test.local/account/me")), + ); + expect(response.status).toBe(401); + }), + ); + + it.effect("GET /account/api-keys returns the caller's keys in the neutral shape", () => + Effect.gen(function* () { + const fetch = makeFetch(authedSession, { + listUserMemberships: stubMemberships([{ organizationId: "org_1", status: "active" }]), + }); + + const response = yield* Effect.promise(() => + fetch(new Request("http://test.local/account/api-keys")), + ); + expect(response.status).toBe(200); + const body = (yield* Effect.promise(() => response.json())) as { + apiKeys: ReadonlyArray<{ id: string; name: string }>; + }; + expect(body.apiKeys).toEqual([ + { + id: "key_1", + name: "Local CLI", + obfuscatedValue: "exk_…a1b2", + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-04-01T00:00:00Z", + lastUsedAt: null, + }, + ]); + }), + ); + + it.effect("GET /account/api-keys returns 403 when the session has no organization", () => + Effect.gen(function* () { + const fetch = makeFetch(orgLessSession); + + const response = yield* Effect.promise(() => + fetch(new Request("http://test.local/account/api-keys")), + ); + expect(response.status).toBe(403); + }), + ); +}); diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts new file mode 100644 index 000000000..4958ca0bd --- /dev/null +++ b/apps/cloud/src/account/workos-account-service.ts @@ -0,0 +1,317 @@ +import { Context, Effect, Layer } from "effect"; + +import { AccountProvider } from "@executor-js/api/server"; +import { + AccountError, + AccountForbidden, + AccountNoOrganization, + AccountUnauthorized, +} from "@executor-js/api"; + +import { ApiKeyService } from "../auth/api-keys"; +import { UserStoreService } from "../auth/context"; +import type { Session } from "../auth/middleware"; +import { WorkOSClient } from "../auth/workos"; +import { authorizeOrganization } from "../auth/organization"; +import { AutumnService } from "../extensions/billing/service"; +import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "../extensions/billing/plans"; + +// The per-request resolved caller, injected by the cookie-only session +// middleware in `account-api.ts`. Carries the authenticated WorkOS session, or +// `null` when the `wos-session` cookie is missing/invalid — the service maps +// `null` to AccountUnauthorized (401) at the method boundary, exactly where the +// inline `requireSession` used to. This is the SINGLE cookie-resolution path: +// the same `WorkOSClient.authenticateSealedSession` the rest of cloud uses. +export class AccountCaller extends Context.Service< + AccountCaller, + { readonly session: Session | null } +>()("@executor-js/cloud/AccountCaller") {} + +// --------------------------------------------------------------------------- +// Cloud AccountProvider — implements the provider-neutral account surface over +// WorkOS. The shared `AccountHandlers` call this; self-host provides its own +// Better Auth implementation of the same shape. +// +// The caller is resolved ONCE per request by the cookie-only session +// middleware in `account-api.ts` (the SAME `WorkOSClient.authenticateSealedSession` +// off the `wos-session` cookie that `SessionAuthLive` uses) and injected here as +// the `SessionContext`. This service no longer parses the cookie itself: there +// is exactly one cookie-resolution path. It still accepts ONLY the wos-session +// sealed-session cookie — it is NOT routed through the api-key-accepting +// executor identity provider — so the credentials `/account/*` accepts are +// byte-identical to before. +// +// It then runs the EXACT logic that used to live in `auth/handlers.ts` +// (me / API keys) and `org/handlers.ts` (members / roles / invite / role / +// name). Native WorkOS / store failures are mapped at this boundary onto the +// neutral account errors so the shared UI sees one shape: +// WorkOSError | UserStoreError | ApiKeyManagementError → AccountError +// no organization in session → AccountNoOrganization +// not-an-admin / over-seat-limit / not-allowed → AccountForbidden +// --------------------------------------------------------------------------- + +const MAX_API_KEY_NAME_LENGTH = 80; + +// Lift any cloud-side tagged failure (WorkOSError / UserStoreError / +// ApiKeyManagementError — none of which carry a safe user-facing message) onto +// the neutral AccountError (500), matching the cloud handlers' httpApiStatus. +const toAccountError = () => Effect.fail(new AccountError({ message: "Account request failed" })); + +export const workosAccountProvider: Layer.Layer< + AccountProvider, + never, + WorkOSClient | UserStoreService | ApiKeyService | AutumnService | AccountCaller +> = Layer.effect(AccountProvider)( + Effect.gen(function* () { + const workos = yield* WorkOSClient; + const apiKeys = yield* ApiKeyService; + const autumn = yield* AutumnService; + const users = yield* UserStoreService; + + // The caller, resolved once per request by the cookie-only session + // middleware (account-api.ts) — the same credential `SessionAuthLive` + // accepts. The method bodies read the already-authenticated session rather + // than re-parsing the cookie. `null` => no/invalid session. + const caller = yield* AccountCaller; + + // Capture the resolved service context once so the method bodies — which + // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) — + // can be erased to `R = never`, as the neutral AccountProvider shape + // requires. Provided per method below. + const ctx = yield* Effect.context(); + + // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly + // as the old inline `requireSession` did. + const requireSession = () => + caller.session + ? Effect.succeed(caller.session) + : Effect.fail(new AccountUnauthorized()); + + // Like cloud's `requireSessionOrganization`: an authenticated session that + // currently holds an active membership in its session org. Yields the + // session + resolved org, or AccountNoOrganization. + const requireOrganization = () => + Effect.gen(function* () { + const session = yield* requireSession(); + if (!session.organizationId) { + return yield* new AccountNoOrganization(); + } + const org = yield* authorizeOrganization(session.accountId, session.organizationId).pipe( + Effect.provideContext(ctx), + Effect.mapError(() => new AccountNoOrganization()), + ); + if (!org) return yield* new AccountNoOrganization(); + return { session, org }; + }); + + // Mirror of org/handlers `requireAdmin`, but scoped to the resolved org. + const requireAdmin = (accountId: string, organizationId: string) => + Effect.gen(function* () { + const membership = yield* workos + .getUserOrgMembership(organizationId, accountId) + .pipe(Effect.catchTag("WorkOSError", toAccountError)); + if (!membership || membership.role?.slug !== "admin") { + return yield* new AccountForbidden(); + } + }); + + // Mirror of org/handlers `assertMembershipInSessionOrg` — ownership check so + // an admin can't mutate a membership id from another org. + const assertMembershipInOrg = (organizationId: string, membershipId: string) => + Effect.gen(function* () { + const membership = yield* workos + .getOrgMembership(membershipId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (!membership || membership.organizationId !== organizationId) { + return yield* new AccountForbidden(); + } + }); + + // Mirror of org/handlers `getMemberSeats` — live seat usage from WorkOS. + const getMemberSeats = (organizationId: string) => + Effect.gen(function* () { + const customer = yield* autumn.use((client) => + client.customers.getOrCreate({ customerId: organizationId }), + ); + const planId = selectActiveMemberLimitPlan(customer.subscriptions); + const limit = getMemberLimitForPlan(planId); + + const memberships = yield* workos.listOrgMembers(organizationId); + const invitations = yield* workos.listPendingInvitations(organizationId); + + return { + used: memberships.data.length + invitations.data.length, + granted: limit ?? 0, + unlimited: limit === null, + }; + }); + + // Mirror of org/handlers `reserveMemberSlot` — fail closed on lookup error. + const reserveMemberSlot = (organizationId: string) => + Effect.gen(function* () { + const seats = yield* getMemberSeats(organizationId).pipe( + Effect.catchCause(() => Effect.fail(new AccountForbidden())), + ); + if (!seats.unlimited && seats.used >= seats.granted) { + return yield* new AccountForbidden(); + } + }); + + return AccountProvider.of({ + me: () => + Effect.gen(function* () { + const session = yield* requireSession(); + const org = session.organizationId + ? yield* authorizeOrganization(session.accountId, session.organizationId).pipe( + Effect.provideContext(ctx), + Effect.orElseSucceed(() => null), + ) + : null; + return { + user: { + id: session.accountId, + email: session.email, + name: session.name, + avatarUrl: session.avatarUrl, + }, + organization: org ? { id: org.id, name: org.name } : null, + }; + }), + + listApiKeys: () => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(); + const keys = yield* apiKeys + .listUserKeys({ accountId: session.accountId, organizationId: org.id }) + .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); + return { apiKeys: keys }; + }), + + createApiKey: (_headers, name) => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(); + const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); + if (!trimmed) { + return yield* new AccountError({ message: "API key name is required" }); + } + return yield* apiKeys + .createUserKey({ accountId: session.accountId, organizationId: org.id, name: trimmed }) + .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); + }), + + revokeApiKey: (_headers, apiKeyId) => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(); + const ownedKeys = yield* apiKeys + .listUserKeys({ accountId: session.accountId, organizationId: org.id }) + .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); + if (!ownedKeys.some((key) => key.id === apiKeyId)) { + return yield* new AccountError({ message: "API key not found" }); + } + yield* apiKeys + .revokeUserKey({ keyId: apiKeyId }) + .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); + return { success: true }; + }), + + listMembers: () => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(); + + // Seats fall back to safe display defaults on lookup error — never + // blank the page over a transient Autumn/WorkOS hiccup. The real cap + // gate lives in `reserveMemberSlot`, which fails closed. + const seats = yield* getMemberSeats(org.id).pipe( + Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })), + ); + + const memberships = yield* workos + .listOrgMembers(org.id) + .pipe(Effect.catchTag("WorkOSError", toAccountError)); + + const members = yield* Effect.all( + memberships.data.map((m) => + Effect.gen(function* () { + const user = yield* workos.getUser(m.userId); + return { + id: m.id, + userId: m.userId, + email: user.email, + name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, + avatarUrl: user.profilePictureUrl ?? null, + role: m.role?.slug ?? "member", + status: m.status, + lastActiveAt: user.lastSignInAt ?? null, + isCurrentUser: m.userId === session.accountId, + }; + }), + ), + { concurrency: 5 }, + ).pipe(Effect.catchTag("WorkOSError", toAccountError)); + + return { members, seats }; + }), + + listRoles: () => + Effect.gen(function* () { + const { org } = yield* requireOrganization(); + const result = yield* workos + .listOrgRoles(org.id) + .pipe(Effect.catchTag("WorkOSError", toAccountError)); + return { + roles: result.data.map((r) => ({ slug: r.slug, name: r.name })), + }; + }), + + inviteMember: (_headers, body) => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(); + yield* requireAdmin(session.accountId, org.id); + yield* reserveMemberSlot(org.id); + const invitation = yield* workos + .sendInvitation({ + email: body.email, + organizationId: org.id, + ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}), + }) + .pipe(Effect.catchTag("WorkOSError", toAccountError)); + return { id: invitation.id, email: invitation.email }; + }), + + removeMember: (_headers, membershipId) => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(); + yield* requireAdmin(session.accountId, org.id); + yield* assertMembershipInOrg(org.id, membershipId); + yield* workos + .deleteOrgMembership(membershipId) + .pipe(Effect.catchTag("WorkOSError", toAccountError)); + return { success: true }; + }), + + updateMemberRole: (_headers, membershipId, roleSlug) => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(); + yield* requireAdmin(session.accountId, org.id); + yield* assertMembershipInOrg(org.id, membershipId); + yield* workos + .updateOrgMembershipRole(membershipId, roleSlug) + .pipe(Effect.catchTag("WorkOSError", toAccountError)); + return { success: true }; + }), + + updateOrgName: (_headers, name) => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(); + yield* requireAdmin(session.accountId, org.id); + const updated = yield* workos + .updateOrganization(org.id, name) + .pipe(Effect.catchTag("WorkOSError", toAccountError)); + yield* users + .use((s) => s.upsertOrganization({ id: updated.id, name: updated.name })) + .pipe(Effect.catchTag("UserStoreError", toAccountError)); + return { name: updated.name }; + }), + } satisfies AccountProvider["Service"]); + }), +); diff --git a/apps/cloud/src/api.request-scope.node.test.ts b/apps/cloud/src/api.request-scope.node.test.ts index 55f6ddba5..e7e9e54ca 100644 --- a/apps/cloud/src/api.request-scope.node.test.ts +++ b/apps/cloud/src/api.request-scope.node.test.ts @@ -25,8 +25,9 @@ import { describe, it, expect } from "@effect/vitest"; import { Context, Effect, Layer } from "effect"; import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"; +import { requestScopedMiddleware } from "@executor-js/api/server"; + import { RequestScopedServicesLive } from "./api/layers"; -import { requestScopedMiddleware } from "./api/request-scoped"; import { makeApiLive } from "./api/router"; class Counter extends Context.Service()("test/Counter") {} diff --git a/apps/cloud/src/api.test.ts b/apps/cloud/src/api.test.ts index db79fd37e..d30785055 100644 --- a/apps/cloud/src/api.test.ts +++ b/apps/cloud/src/api.test.ts @@ -16,6 +16,7 @@ import { } from "effect/unstable/http"; import { expect, layer } from "@effect/vitest"; import { Cause, Effect, Layer, Schema } from "effect"; +import { RouterConfigLive } from "@executor-js/api/server"; import { toErrorServerResponse } from "./api/error-response"; const SourceResponse = Schema.Struct({ source: Schema.String }); @@ -115,8 +116,6 @@ const TestProtectedGate = HttpRouter.middleware()((httpEffect) => // Wire test APIs as route layers + autumn route, mirroring prod's structure. // --------------------------------------------------------------------------- -const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); - const OrgTestLive = HttpApiBuilder.layer(OrgTestApi).pipe(Layer.provide(OrgTestHandlers)); const AuthTestLive = HttpApiBuilder.layer(AuthTestApi).pipe(Layer.provide(AuthHandlers)); const ProtectedTestLive = HttpApiBuilder.layer(ProtectedTestApi).pipe( @@ -145,7 +144,7 @@ const TestApiLive = Layer.mergeAll( TestDocsLive, ProtectedTestLive, AutumnTestRoutesLive, -).pipe(Layer.provideMerge(RouterConfig), Layer.provideMerge(HttpServer.layerServices)); +).pipe(Layer.provideMerge(RouterConfigLive), Layer.provideMerge(HttpServer.layerServices)); const requestHandler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler; diff --git a/apps/cloud/src/api.ts b/apps/cloud/src/api.ts deleted file mode 100644 index 7e1293384..000000000 --- a/apps/cloud/src/api.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { HttpRouter } from "effect/unstable/http"; - -import { ApiLive } from "./api/router"; - -export const handleApiRequest = HttpRouter.toWebHandler(ApiLive).handler; diff --git a/apps/cloud/src/api/core-shared-services.ts b/apps/cloud/src/api/core-shared-services.ts deleted file mode 100644 index 2aa3bc681..000000000 --- a/apps/cloud/src/api/core-shared-services.ts +++ /dev/null @@ -1,25 +0,0 @@ -// --------------------------------------------------------------------------- -// Core shared services — the Effect layer that both the stateless HTTP -// request path and the long-lived MCP session DO build on top of. -// --------------------------------------------------------------------------- -// -// Pulled out of `./layers.ts` so importers that only need `WorkOSAuth` and -// `AutumnService` (notably the MCP session DO) don't have to drag in -// `auth/handlers.ts`, which imports `@tanstack/react-start/server`. That -// import uses a subpath specifier (`#tanstack-start-entry`) that vitest's -// workerd pool can't resolve, so any test that touches the DO through -// SELF.fetch would fail at module load. -// --------------------------------------------------------------------------- - -import { Layer } from "effect"; - -import { WorkOSAuth } from "../auth/workos"; -import { AutumnService } from "../services/autumn"; - -/** - * Services that are independent of how the DB or tracer is provisioned — - * both the stateless HTTP path (per-request DB via Hyperdrive) and the MCP - * session DO (long-lived DB + isolate-local tracer SDK) merge this with - * their own `DbLive` + `UserStoreLive` + telemetry layer. - */ -export const CoreSharedServices = Layer.mergeAll(WorkOSAuth.Default, AutumnService.Default); diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index fb6abdf4b..d879329a3 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -2,24 +2,25 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServer } from "effect/unstable/http"; import { Layer } from "effect"; +import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server"; + import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; -import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, NonProtectedApi, } from "../auth/handlers"; -import { DbService } from "../services/db"; -import { TelemetryLive } from "../services/telemetry"; -import { OrgHttpApi } from "../org/compose"; +import { DbService } from "../db/db"; +import { WorkerTelemetryLive } from "../observability/telemetry"; +import { OrgHttpApi } from "../org/api"; import { OrgHandlers } from "../org/handlers"; +import { ErrorCaptureLive } from "../observability"; -import { CoreSharedServices } from "./core-shared-services"; -import { ProtectedCloudApi, RouterConfig } from "./protected-layers"; -import { requestScopedMiddleware } from "./request-scoped"; +import { AutumnService } from "../extensions/billing/service"; -export { CoreSharedServices, ProtectedCloudApi, RouterConfig }; +import { cloudPlugins } from "../plugins"; +import { CoreSharedServices } from "../auth/workos"; const DbLive = DbService.Live; const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); @@ -36,7 +37,7 @@ export const RequestScopedServicesLive = Layer.mergeAll(DbLive, UserStoreLive); export const BootSharedServices = Layer.mergeAll( CoreSharedServices, HttpServer.layerServices, - TelemetryLive, + WorkerTelemetryLive, ); // Routes that don't require an authenticated org session — login, @@ -48,25 +49,70 @@ export const BootSharedServices = Layer.mergeAll( // without per-request scoping the postgres.js socket pins to the worker's // 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.) export const makeNonProtectedApiLive = (rsLive: Layer.Layer) => HttpApiBuilder.layer(NonProtectedApi).pipe( Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), - Layer.provideMerge(ApiKeyService.WorkOS), Layer.provide(requestScopedMiddleware(rsLive).layer), Layer.provideMerge(SessionAuthLive), + Layer.provideMerge(AutumnService.Default), ); -// Routes scoped to a specific org (membership management, switching, etc.). -// Auth is enforced by `OrgAuth` middleware declared on `OrgHttpApi`. -export const makeOrgApiLive = (rsLive: Layer.Layer) => - HttpApiBuilder.layer(OrgHttpApi).pipe( - Layer.provide(OrgHandlers), - Layer.provide(requestScopedMiddleware(rsLive).layer), - Layer.provideMerge(OrgAuthLive), - ); +// Cloud-only WorkOS domain-verification routes. Auth is enforced by `OrgAuth` +// middleware declared on `OrgHttpApi`. The domain handlers read the boot +// `WorkOSClient` plus the `AuthContext` from `OrgAuthLive`; the +// `getDomainVerificationLink` handler also gates on billing, so +// `AutumnService.Default` is provided HERE (not on the neutral boot core). +// Unlike the member endpoints that used to live here, they need no per-request +// DB scoping. +export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe( + Layer.provide(OrgHandlers), + Layer.provideMerge(OrgAuthLive), + Layer.provideMerge(AutumnService.Default), +); -// Default exports use the production per-request layer. Existing callers -// that import `NonProtectedApiLive`/`OrgApiLive` continue to work; the -// `make*` factories exist for tests that need to swap in a fake. +// Default export uses the production per-request layer. Existing callers that +// import `NonProtectedApiLive` continue to work; the `make*` factory exists for +// tests that need to swap in a fake. export const NonProtectedApiLive = makeNonProtectedApiLive(RequestScopedServicesLive); -export const OrgApiLive = makeOrgApiLive(RequestScopedServicesLive); + +// --------------------------------------------------------------------------- +// Protected API +// --------------------------------------------------------------------------- +// +// `ProtectedCloudApi` deliberately does NOT declare `.middleware(OrgAuth)` +// — auth + per-request execution stack construction live in a single +// `HttpRouter` middleware (`ExecutionStackMiddleware` in `./protected.ts`) +// which has the right ordering to provide `AuthContext` AND the executor +// services to handlers. Putting auth on the API as `HttpApiMiddleware` ran +// it INSIDE the router middleware (wrong order), and added a second auth +// pass on top of the existing one in `protected.ts`'s outer effect. The +// router-middleware approach folds both into one place. +// +// The shared `makeProtectedApiLayer` assembles the protected API the same way +// every host does: `composePluginApi(cloudPlugins)` -> +// `observabilityMiddleware` -> `HttpApiBuilder.layer` provided with +// `CoreHandlers` + `composePluginHandlerLayer(cloudPlugins)` + the host's +// `ErrorCapture` + `RouterConfigLive`. Cloud serves at root (no prefixed +// router) and passes the Sentry-backed `ErrorCaptureLive` (provided ABOVE the +// handler + middleware layers, so the `capture(...)` translation path AND the +// observability middleware's defect catchall both resolve the same Sentry +// implementation). +// +// `api` is precisely typed (`HttpApi<…, CoreGroups | PluginGroups>`); test harness clients type via +// `HttpApiClient.ForApi` with no per-plugin imports. +// `handlers` is the late-binding plugin handler Layer (each plugin's +// `extensionService` Tag stays a requirement, satisfied per-request by +// `ExecutionStackMiddleware` in `./protected.ts`). `RouterConfigLive` is +// folded into `.layer` here; the rest of the router (`makeApiLive` in +// `./router.ts`, `./protected.ts`, the test harness) re-provides the same +// shared `RouterConfigLive` directly. +const protectedApi = makeProtectedApiLayer(cloudPlugins, { errorCapture: ErrorCaptureLive }); + +export const ProtectedCloudApi = protectedApi.api; +export const ProtectedCloudApiHandlers = protectedApi.handlers; +export const ProtectedCloudApiLive = protectedApi.layer; diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 8e5278f92..a74764b61 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -3,8 +3,8 @@ import { Effect, Layer } from "effect"; import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; -import { WorkOSAuth, type WorkOSAuthService } from "../auth/workos"; -import { resolveProtectedIdentity } from "./protected"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { resolveProtectedPrincipal } from "./protected"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); @@ -25,8 +25,8 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ }); const stubWorkOS = Layer.succeed( - WorkOSAuth, - new Proxy({} as WorkOSAuthService, { + WorkOSClient, + new Proxy({} as WorkOSClientService, { get: (_target, prop) => { if (prop === "listUserMemberships") { return (userId: string) => @@ -37,7 +37,7 @@ const stubWorkOS = Layer.succeed( : [], }); } - return () => Effect.die(`unexpected WorkOSAuth.${String(prop)} call`); + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), ); @@ -52,13 +52,17 @@ const stubUsers = Layer.succeed(UserStoreService)({ ...org, createdAt, }), - getOrganization: async (id: string) => ({ id, name: `Org ${id}`, createdAt }), + getOrganization: async (id: string) => ({ + id, + name: `Org ${id}`, + createdAt, + }), }), ), }); const run = (request: Request) => - resolveProtectedIdentity(request).pipe( + resolveProtectedPrincipal(request).pipe( Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)), ); @@ -78,6 +82,7 @@ describe("protected API key auth", () => { email: "", name: null, avatarUrl: null, + roles: [], }); }), ); @@ -92,9 +97,13 @@ describe("protected API key auth", () => { ), ); + // The resolver now raises the SHARED `Unauthorized` carrying the same + // machine code; cloud's failure strategy renders it as the byte-identical + // 401 `{ error: "Invalid API key", code: "invalid_api_key" }`. expect(error).toMatchObject({ - status: 401, + _tag: "Unauthorized", code: "invalid_api_key", + message: "Invalid API key", }); }), ); diff --git a/apps/cloud/src/api/protected-layers.ts b/apps/cloud/src/api/protected-layers.ts deleted file mode 100644 index 5683f5574..000000000 --- a/apps/cloud/src/api/protected-layers.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Protected-side API wiring. Kept separate from `./layers.ts` so tests -// can import the protected API + shared services without dragging in -// non-protected/org handlers (which transitively import -// `@tanstack/react-start`, unresolvable in the Workers test runtime). - -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { HttpRouter, HttpServer } from "effect/unstable/http"; -import { Layer } from "effect"; - -import { observabilityMiddleware } from "@executor-js/api"; -import { CoreHandlers, composePluginApi, composePluginHandlerLayer } from "@executor-js/api/server"; - -import { cloudPlugins } from "./cloud-plugins"; -import { UserStoreService } from "../auth/context"; -import { WorkOSAuth } from "../auth/workos"; -import { AutumnService } from "../services/autumn"; -import { DbService } from "../services/db"; -import { ErrorCaptureLive } from "../observability"; - -// `ProtectedCloudApi` deliberately does NOT declare `.middleware(OrgAuth)` -// — auth + per-request execution stack construction live in a single -// `HttpRouter` middleware (`ExecutionStackMiddleware` in `./protected.ts`) -// which has the right ordering to provide `AuthContext` AND the executor -// services to handlers. Putting auth on the API as `HttpApiMiddleware` ran -// it INSIDE the router middleware (wrong order), and added a second auth -// pass on top of the existing one in `protected.ts`'s outer effect. The -// router-middleware approach folds both into one place. -// -// `composePluginApi(cloudPlugins)` returns a precisely typed `HttpApi` -// — the group union is derived from `typeof cloudPlugins` via the -// plugin spec's `TGroup` generic. Test harness clients type via -// `HttpApiClient.ForApi` directly, with no -// per-plugin Group imports at the host. -export const ProtectedCloudApi = composePluginApi(cloudPlugins); - -const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); - -const DbLive = DbService.Live; -const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); - -export const SharedServices = Layer.mergeAll( - DbLive, - UserStoreLive, - WorkOSAuth.Default, - AutumnService.Default, - HttpServer.layerServices, -); - -export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); - -// Every handler the ProtectedCloudApi routes to. Plugin handler layers -// are late-binding — they require their plugin's `extensionService` -// Tag, which the per-request `ExecutionStackMiddleware` satisfies via -// `providePluginExtensions`. The test harness mirrors this; nothing -// else needs to know which plugins are wired. -export const ProtectedCloudApiHandlers = Layer.mergeAll( - CoreHandlers, - composePluginHandlerLayer(cloudPlugins), -); - -// `ErrorCaptureLive` is provided above the handler + middleware layers -// so the `withCapture` translation path (typed-channel `StorageError → -// InternalError(traceId)`) AND the observability middleware's defect -// catchall both see the same Sentry-backed implementation. -export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( - Layer.provide(Layer.mergeAll(ProtectedCloudApiHandlers, ObservabilityLive)), - Layer.provide(ErrorCaptureLive), -); diff --git a/apps/cloud/src/api/protected.test.ts b/apps/cloud/src/api/protected.test.ts index d6ff28874..5a1a02283 100644 --- a/apps/cloud/src/api/protected.test.ts +++ b/apps/cloud/src/api/protected.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import type { ExecutionEngine } from "@executor-js/execution"; -import { withExecutionUsageTracking } from "./execution-usage"; +import { withExecutionUsageTracking } from "../engine/execution-usage"; const makeBaseEngine = (): ExecutionEngine => ({ @@ -24,8 +24,8 @@ describe("withExecutionUsageTracking", () => { it.effect("tracks successful execute and executeWithPause", () => Effect.gen(function* () { const tracked: string[] = []; - const engine = withExecutionUsageTracking("org_1", makeBaseEngine(), (orgId) => { - tracked.push(orgId); + const engine = withExecutionUsageTracking("org_1", makeBaseEngine(), (organizationId) => { + tracked.push(organizationId); }); yield* engine.execute("1+1", { onElicitation: () => Effect.die("unused") }); @@ -50,8 +50,8 @@ describe("withExecutionUsageTracking", () => { return base.resume(...args); }, }, - (orgId) => { - tracked.push(orgId); + (organizationId) => { + tracked.push(organizationId); }, ); diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index 07f426513..3d8a513bb 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -1,212 +1,123 @@ -// Production wiring for the protected API. Lives outside `protected-layers.ts` -// because `makeExecutionStack` imports `cloudflare:workers`, which the test -// harness can't load in the workerd test runtime. +// Production wiring for the protected API: the per-request HttpRouter +// middleware that resolves identity, builds the executor/engine, and provides +// `AuthContext` + the execution-stack services to handlers. -import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { Effect, Layer } from "effect"; import { - ExecutionEngineService, - ExecutorService, - providePluginExtensions, - type PluginExtensionServices, + IdentityProvider, + makeExecutionStackMiddleware, + requestScopedMiddleware, + RouterConfigLive, + type IdentityFailure, } from "@executor-js/api/server"; -import { cloudPlugins, type CloudPlugins } from "./cloud-plugins"; -import { AuthContext } from "../auth/middleware"; +import { cloudPlugins, type CloudPlugins } from "../plugins"; import { ApiKeyService } from "../auth/api-keys"; -import { authorizeOrganization } from "../auth/authorize-organization"; import { UserStoreService } from "../auth/context"; -import { WorkOSAuth } from "../auth/workos"; -import { AutumnService } from "../services/autumn"; -import { DbService } from "../services/db"; -import { makeExecutionStack } from "../services/execution-stack"; -import { HttpResponseError } from "./error-response"; -import { RequestScopedServicesLive } from "./layers"; -import { ProtectedCloudApiLive, RouterConfig } from "./protected-layers"; -import { requestScopedMiddleware } from "./request-scoped"; - -// Pre-compute the per-plugin `Effect.provideService(extensionService, -// executor[id])` chain. The plugin spec carries the Service tag so -// this file doesn't import each plugin's `*/api` directly. -const provideExecutorExtensions = providePluginExtensions(cloudPlugins); -const BEARER_PREFIX = "Bearer "; - -export const resolveApiKeyIdentity = (request: Request) => - Effect.gen(function* () { - const authHeader = request.headers.get("authorization"); - if (!authHeader) return null; - - if (!authHeader.startsWith(BEARER_PREFIX)) { - return yield* new HttpResponseError({ - status: 401, - code: "invalid_authorization_header", - message: "Authorization header must use Bearer authentication", - }); - } - - const value = authHeader.slice(BEARER_PREFIX.length).trim(); - if (!value) { - return yield* new HttpResponseError({ - status: 401, - code: "invalid_api_key", - message: "Invalid API key", - }); - } - - const apiKeys = yield* ApiKeyService; - const principal = yield* apiKeys.validate(value).pipe( - Effect.catchTag("ApiKeyValidationError", () => - Effect.fail( - new HttpResponseError({ - status: 503, - code: "api_key_validation_unavailable", - message: "API key validation is temporarily unavailable", - }), - ), - ), - ); - - if (!principal) { - return yield* new HttpResponseError({ - status: 401, - code: "invalid_api_key", - message: "Invalid API key", - }); - } - - const org = yield* authorizeOrganization(principal.accountId, principal.organizationId); - if (!org) { - return yield* new HttpResponseError({ - status: 403, - code: "no_organization", - message: "No organization in API key", - }); - } - - return { - accountId: principal.accountId, - organizationId: org.id, - organizationName: org.name, - email: "", - name: null, - avatarUrl: null, - }; - }); - -export const resolveSessionIdentity = (request: Request) => - Effect.gen(function* () { - const workos = yield* WorkOSAuth; - const session = yield* workos.authenticateRequest(request); - if (!session || !session.organizationId) { - return yield* new HttpResponseError({ - status: 403, - code: "no_organization", - message: "No organization in session", - }); - } - const org = yield* authorizeOrganization(session.userId, session.organizationId); - if (!org) { - return yield* new HttpResponseError({ - status: 403, - code: "no_organization", - message: "No organization in session", - }); - } - return { - accountId: session.userId, - organizationId: org.id, - organizationName: org.name, - email: session.email, - name: `${session.firstName ?? ""} ${session.lastName ?? ""}`.trim() || null, - avatarUrl: session.avatarUrl ?? null, - }; - }); - -export const resolveProtectedIdentity = (request: Request) => - Effect.gen(function* () { - const apiKeyIdentity = yield* resolveApiKeyIdentity(request); - if (apiKeyIdentity) return apiKeyIdentity; - return yield* resolveSessionIdentity(request); - }); +import { cloudIdentityFailureStrategy, workosIdentityLayer } from "../auth/workos-auth-provider"; +import { AutumnService } from "../extensions/billing/service"; +import { DbService } from "../db/db"; +import { CoreSharedServices } from "../auth/workos"; +import { CloudMeteredExecutionStackLayer } from "../engine/execution-stack-metered"; +import { ProtectedCloudApiLive, RequestScopedServicesLive } from "./layers"; + +// Re-exported for `protected-api-key-auth.node.test.ts`, which asserts the +// per-path principal + error codes the folded resolver still produces. +export { + resolveApiKeyPrincipal, + resolveSessionPrincipal, + resolveProtectedPrincipal, +} from "../auth/workos-auth-provider"; // One `HttpRouter` middleware that: -// 1. authenticates the WorkOS sealed session, -// 2. verifies live org membership (closes the JWT-cache gap — see -// `auth/authorize-organization.ts`), -// 3. resolves the org name, -// 4. builds the per-request executor + engine, -// 5. provides `AuthContext` + the execution-stack services to the handler. +// 1. resolves identity via the NEUTRAL `IdentityProvider` (api-key BEATS sealed +// session, decided INSIDE cloud's `workosIdentityLayer`), verifying live org +// membership, +// 2. builds the per-request executor + engine, +// 3. provides `AuthContext` + the execution-stack services to the handler. // // Replaces both the old outer `Effect.gen` in this file (which did its own // WorkOS lookup) and the per-route `OrgAuth` HttpApiMiddleware (which did // a second one). // -// Errors are NOT caught here: failures propagate as typed errors and are -// rendered to a JSON response by the framework's `Respondable` pipeline -// (see `HttpResponseError` in `./error-response.ts`). Letting `unhandled` -// pass through is what satisfies `HttpRouter.middleware`'s brand check -// without any type casts. +// The shared `makeExecutionStackMiddleware` (P5) owns the body; cloud injects: +// - the neutral `IdentityProvider` -> the identity seam. Cloud's +// `workosIdentityLayer` provides this tag; it +// reads the per-request `UserStoreService`, so +// it is built PER REQUEST in the DB combine +// below (NOT captured at boot). +// - `cloudIdentityFailureStrategy` -> renders the shared identity errors as +// cloud's exact `{ error, code }` JSON at +// status 401/403/503 (byte-identical). +// - `cloudPlugins` + `CloudMeteredExecutionStackLayer` — the executor plane is +// the ONLY path that meters, so billing lives +// here (not in the neutral stack the DO shares). // -// `DbService` and `UserStoreService` are pulled from per-request context -// — `RequestScopedServicesMiddleware` (combined below) provides them -// fresh per request so the postgres.js socket lives in the request -// fiber's scope, not the worker's boot scope. -const ExecutionStackMiddleware = HttpRouter.middleware<{ - // The plugin extension Services this middleware satisfies are derived - // from `typeof cloudPlugins` — no per-plugin `*ExtensionService` - // imports at the host. Runtime binding mirrors the type: - // `providePluginExtensions(cloudPlugins)(executor)` below. - provides: - | AuthContext - | ExecutorService - | ExecutionEngineService - | PluginExtensionServices; -}>()( - Effect.gen(function* () { - const longLived = yield* Effect.context(); - return (httpEffect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const webRequest = yield* HttpServerRequest.toWeb(request); - const identity = yield* resolveProtectedIdentity(webRequest); - const auth = AuthContext.of({ - accountId: identity.accountId, - organizationId: identity.organizationId, - email: identity.email, - name: identity.name, - avatarUrl: identity.avatarUrl, - }); - const { executor, engine } = yield* makeExecutionStack( - auth.accountId, - identity.organizationId, - identity.organizationName, - ); - return yield* httpEffect.pipe( - Effect.provideService(AuthContext, auth), - Effect.provideService(ExecutorService, executor), - Effect.provideService(ExecutionEngineService, engine), - provideExecutorExtensions(executor), - ); - }).pipe(Effect.provideContext(longLived)); - }), -); - -// `rsLive` is the per-request DB layer. Combining it into the auth -// middleware collapses `requires: DbService | UserStoreService` to -// never (so `.layer` is a real Layer instead of the "Need to combine" -// type-error sentinel) AND makes the postgres.js socket request-scoped: -// the layer rebuilds per HTTP request, satisfying Cloudflare Workers' -// I/O isolation. Exposed as a factory so tests can swap in a counting -// fake — see `apps/cloud/src/api.request-scope.node.test.ts`. +// Only `AutumnService` is captured at boot; `IdentityProvider` + `DbService` + +// `UserStoreService` stay residual and are supplied per request by the combined +// `requestScopedMiddleware` (so the postgres.js socket — and the identity layer +// that reads it — live in the request fiber's scope, satisfying Cloudflare +// Workers' I/O isolation). +const ExecutionStackMiddleware = makeExecutionStackMiddleware< + CloudPlugins, + IdentityFailure, + IdentityProvider, + AutumnService | DbService, + never, + // Capture only the boot-scoped `AutumnService`; `IdentityProvider` + `DbService` + // + `UserStoreService` stay residual and flow through the per-request DB combine. + AutumnService +>({ + plugins: cloudPlugins, + authenticate: (request) => + IdentityProvider.asEffect().pipe(Effect.flatMap((provider) => provider.authenticate(request))), + strategy: cloudIdentityFailureStrategy, + stackLayer: CloudMeteredExecutionStackLayer, +}); + +// `rsLive` is the per-request DB layer. `requestScopedLive` folds the neutral +// `IdentityProvider` (cloud's `workosIdentityLayer`, which reads the per-request +// `UserStoreService` from `rsLive` and the boot `WorkOSClient` / `ApiKeyService` +// residually) ON TOP of it, so the identity layer is rebuilt per request in the +// same request-fiber scope as the postgres.js socket it reads — satisfying +// Cloudflare Workers' I/O isolation. Combining it into the auth middleware +// collapses `requires: IdentityProvider | DbService | UserStoreService` to the +// boot-only `WorkOSClient | ApiKeyService` (so `.layer` is a real Layer instead +// of the "Need to combine" sentinel). Exposed as a factory so tests can swap in a +// counting fake — see `apps/cloud/src/api.request-scope.node.test.ts`. +// +// `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.) export const makeProtectedApiLive = (rsLive: Layer.Layer) => { + // The neutral `IdentityProvider`, built per request: it reads `UserStoreService` + // from `rsLive` and the WorkOS control plane (`WorkOSClient` + `ApiKeyService`, + // stateless config — no per-request I/O socket) for the org-resolution path. + // `orDie` because a WorkOS config error is unrecoverable. + const identityLive = workosIdentityLayer.pipe( + Layer.provide(rsLive), + Layer.provide(ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices))), + Layer.provide(CoreSharedServices), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a boot-time WorkOS misconfiguration is unrecoverable + Layer.orDie, + ); + // The per-request layer the combine rebuilds in the request fiber's scope: the + // postgres socket (`rsLive`) PLUS the identity layer that reads it. Combining it + // into the auth middleware collapses `requires: IdentityProvider | DbService | + // UserStoreService` to `never` (so `.layer` is a real Layer instead of the "Need + // to combine" sentinel) AND keeps the socket request-scoped. Exposed as a + // factory so tests can swap in a counting fake — see + // `apps/cloud/src/api.request-scope.node.test.ts`. + const requestScopedLive = rsLive.pipe(Layer.provideMerge(identityLive)); const protectedMiddleware = ExecutionStackMiddleware.combine( - requestScopedMiddleware(rsLive), + requestScopedMiddleware(requestScopedLive), ).layer; return ProtectedCloudApiLive.pipe( Layer.provide(protectedMiddleware), - Layer.provideMerge(ApiKeyService.WorkOS), - Layer.provideMerge(RouterConfig), + Layer.provideMerge(AutumnService.Default), + Layer.provideMerge(RouterConfigLive), ); }; diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts index 956d2f718..91b6f4895 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -1,17 +1,20 @@ import { Layer } from "effect"; +import { HttpRouter } from "effect/unstable/http"; + +import { RouterConfigLive } from "@executor-js/api/server"; import { UserStoreService } from "../auth/context"; -import { DbService } from "../services/db"; +import { DbService } from "../db/db"; +import { makeAccountApiLive } from "../account/account-api"; -import { AutumnRoutesLive } from "./autumn"; -import { CloudDocsLive } from "./docs"; -import { ApiErrorLoggingLive } from "./error-logging"; +import { AutumnRoutesLive } from "../extensions/billing/route"; +import { CloudDocsLive } from "../extensions/docs"; +import { ApiErrorLoggingLive } from "../observability/error-logging"; import { BootSharedServices, + OrgApiLive, RequestScopedServicesLive, - RouterConfig, makeNonProtectedApiLive, - makeOrgApiLive, } from "./layers"; import { makeProtectedApiLive } from "./protected"; @@ -29,11 +32,14 @@ import { makeProtectedApiLive } from "./protected"; export const makeApiLive = (requestScopedLive: Layer.Layer) => Layer.mergeAll( makeNonProtectedApiLive(requestScopedLive), - makeOrgApiLive(requestScopedLive), + OrgApiLive, + makeAccountApiLive(requestScopedLive), CloudDocsLive, makeProtectedApiLive(requestScopedLive), AutumnRoutesLive, ApiErrorLoggingLive, - ).pipe(Layer.provideMerge(RouterConfig), Layer.provideMerge(BootSharedServices)); + ).pipe(Layer.provideMerge(RouterConfigLive), Layer.provideMerge(BootSharedServices)); export const ApiLive = makeApiLive(RequestScopedServicesLive); + +export const handleApiRequest = HttpRouter.toWebHandler(ApiLive).handler; diff --git a/apps/cloud/src/services/secrets-api.node.test.ts b/apps/cloud/src/api/secrets-api.node.test.ts similarity index 98% rename from apps/cloud/src/services/secrets-api.node.test.ts rename to apps/cloud/src/api/secrets-api.node.test.ts index 87cbbe211..28c94efb1 100644 --- a/apps/cloud/src/services/secrets-api.node.test.ts +++ b/apps/cloud/src/api/secrets-api.node.test.ts @@ -6,7 +6,7 @@ import { Effect, Result } from "effect"; import { ScopeId, SecretId } from "@executor-js/sdk"; -import { asOrg, fetchForOrg, TEST_BASE_URL } from "./__test-harness__/api-harness"; +import { asOrg, fetchForOrg, TEST_BASE_URL } from "../testing/api-harness"; describe("secrets api (HTTP)", () => { it.effect("set → list → status returns secret metadata", () => diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/api/sources-api.node.test.ts similarity index 93% rename from apps/cloud/src/services/sources-api.node.test.ts rename to apps/cloud/src/api/sources-api.node.test.ts index 742284da3..8287ee84f 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/api/sources-api.node.test.ts @@ -22,7 +22,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { secretsForCredentialTarget } from "@executor-js/react/plugins/secret-header-auth"; -import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; +import { asOrg, asUser, testUserOrgScopeId } from "../testing/api-harness"; const isJsonObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); @@ -337,12 +337,12 @@ describe("sources api (HTTP)", () => { Effect.succeed(authorization === "Bearer github-token"), }, }); - const orgId = `org_${crypto.randomUUID()}`; + const organizationId = `org_${crypto.randomUUID()}`; const userId = `user_${crypto.randomUUID()}`; - const userScope = testUserOrgScopeId(userId, orgId); + const userScope = testUserOrgScopeId(userId, organizationId); const namespace = `github_graphql_${crypto.randomUUID().replace(/-/g, "_")}`; - yield* asUser(userId, orgId, (client) => + yield* asUser(userId, organizationId, (client) => client.secrets.set({ params: { scopeId: ScopeId.make(userScope) }, payload: { @@ -353,9 +353,9 @@ describe("sources api (HTTP)", () => { }), ); - const added = yield* asUser(userId, orgId, (client) => + const added = yield* asUser(userId, organizationId, (client) => client.graphql.addSource({ - params: { scopeId: ScopeId.make(orgId) }, + params: { scopeId: ScopeId.make(organizationId) }, payload: { endpoint: server.endpoint, namespace, @@ -596,16 +596,16 @@ describe("sources api (HTTP)", () => { it.effect("per-user source bindings isolate personal credentials over HTTP", () => Effect.gen(function* () { - const orgId = `org_${crypto.randomUUID()}`; + const organizationId = `org_${crypto.randomUUID()}`; const aliceId = `user_${crypto.randomUUID().slice(0, 8)}`; const bobId = `user_${crypto.randomUUID().slice(0, 8)}`; const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; - const aliceScope = testUserOrgScopeId(aliceId, orgId); - const bobScope = testUserOrgScopeId(bobId, orgId); + const aliceScope = testUserOrgScopeId(aliceId, organizationId); + const bobScope = testUserOrgScopeId(bobId, organizationId); - yield* asOrg(orgId, (client) => + yield* asOrg(organizationId, (client) => client.openapi.addSpec({ - params: { scopeId: ScopeId.make(orgId) }, + params: { scopeId: ScopeId.make(organizationId) }, payload: { ...makeMinimalOpenApiSourcePayload(namespace), headers: { @@ -618,7 +618,7 @@ describe("sources api (HTTP)", () => { }), ); - yield* asUser(aliceId, orgId, (client) => + yield* asUser(aliceId, organizationId, (client) => Effect.gen(function* () { yield* client.secrets.set({ params: { scopeId: ScopeId.make(aliceScope) }, @@ -632,7 +632,7 @@ describe("sources api (HTTP)", () => { params: { scopeId: ScopeId.make(aliceScope) }, payload: { scope: ScopeId.make(aliceScope), - source: { id: namespace, scope: ScopeId.make(orgId) }, + source: { id: namespace, scope: ScopeId.make(organizationId) }, slotKey: "header:authorization", value: { kind: "secret", @@ -642,7 +642,7 @@ describe("sources api (HTTP)", () => { }); expect(binding).toMatchObject({ sourceId: namespace, - sourceScopeId: ScopeId.make(orgId), + sourceScopeId: ScopeId.make(organizationId), scopeId: ScopeId.make(aliceScope), slotKey: "header:authorization", value: { @@ -655,7 +655,7 @@ describe("sources api (HTTP)", () => { }), ); - yield* asUser(bobId, orgId, (client) => + yield* asUser(bobId, organizationId, (client) => Effect.gen(function* () { yield* client.secrets.set({ params: { scopeId: ScopeId.make(bobScope) }, @@ -669,7 +669,7 @@ describe("sources api (HTTP)", () => { params: { scopeId: ScopeId.make(bobScope) }, payload: { scope: ScopeId.make(bobScope), - source: { id: namespace, scope: ScopeId.make(orgId) }, + source: { id: namespace, scope: ScopeId.make(organizationId) }, slotKey: "header:authorization", value: { kind: "secret", @@ -680,12 +680,12 @@ describe("sources api (HTTP)", () => { }), ); - const aliceBindings = yield* asUser(aliceId, orgId, (client) => + const aliceBindings = yield* asUser(aliceId, organizationId, (client) => client.sources.listBindings({ params: { scopeId: ScopeId.make(aliceScope), sourceId: namespace, - sourceScopeId: ScopeId.make(orgId), + sourceScopeId: ScopeId.make(organizationId), }, }), ); @@ -709,12 +709,12 @@ describe("sources api (HTTP)", () => { ), ).toBe(false); - const bobBindings = yield* asUser(bobId, orgId, (client) => + const bobBindings = yield* asUser(bobId, organizationId, (client) => client.sources.listBindings({ params: { scopeId: ScopeId.make(bobScope), sourceId: namespace, - sourceScopeId: ScopeId.make(orgId), + sourceScopeId: ScopeId.make(organizationId), }, }), ); @@ -738,24 +738,26 @@ describe("sources api (HTTP)", () => { ), ).toBe(false); - const sources = yield* asOrg(orgId, (client) => - client.sources.list({ params: { scopeId: ScopeId.make(orgId) } }), + const sources = yield* asOrg(organizationId, (client) => + client.sources.list({ params: { scopeId: ScopeId.make(organizationId) } }), + ); + expect(sources.find((source) => source.id === namespace)?.scopeId).toBe( + ScopeId.make(organizationId), ); - expect(sources.find((source) => source.id === namespace)?.scopeId).toBe(ScopeId.make(orgId)); }), ); it.effect("personal source override picker can see org-owned secrets over HTTP", () => Effect.gen(function* () { - const orgId = `org_${crypto.randomUUID()}`; + const organizationId = `org_${crypto.randomUUID()}`; const aliceId = `user_${crypto.randomUUID().slice(0, 8)}`; const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; - const aliceScope = testUserOrgScopeId(aliceId, orgId); + const aliceScope = testUserOrgScopeId(aliceId, organizationId); - yield* asOrg(orgId, (client) => + yield* asOrg(organizationId, (client) => Effect.gen(function* () { yield* client.openapi.addSpec({ - params: { scopeId: ScopeId.make(orgId) }, + params: { scopeId: ScopeId.make(organizationId) }, payload: { ...makeMinimalOpenApiSourcePayload(namespace), headers: { @@ -768,7 +770,7 @@ describe("sources api (HTTP)", () => { }); yield* client.secrets.set({ - params: { scopeId: ScopeId.make(orgId) }, + params: { scopeId: ScopeId.make(organizationId) }, payload: { id: SecretId.make("shared_pat"), name: "Shared PAT", @@ -778,7 +780,7 @@ describe("sources api (HTTP)", () => { }), ); - const secrets = yield* asUser(aliceId, orgId, (client) => + const secrets = yield* asUser(aliceId, organizationId, (client) => client.secrets.listAll({ params: { scopeId: ScopeId.make(aliceScope) } }), ); @@ -790,12 +792,12 @@ describe("sources api (HTTP)", () => { })); expect(pickerSecrets).toContainEqual( - expect.objectContaining({ id: "shared_pat", scopeId: orgId }), + expect.objectContaining({ id: "shared_pat", scopeId: organizationId }), ); expect( secretsForCredentialTarget(pickerSecrets, ScopeId.make(aliceScope), [ { id: ScopeId.make(aliceScope) }, - { id: ScopeId.make(orgId) }, + { id: ScopeId.make(organizationId) }, ]).map((secret) => secret.id), ).toContain("shared_pat"); }), diff --git a/apps/cloud/src/services/sources-refresh.node.test.ts b/apps/cloud/src/api/sources-refresh.node.test.ts similarity index 98% rename from apps/cloud/src/services/sources-refresh.node.test.ts rename to apps/cloud/src/api/sources-refresh.node.test.ts index a615397ff..63e2d68b4 100644 --- a/apps/cloud/src/services/sources-refresh.node.test.ts +++ b/apps/cloud/src/api/sources-refresh.node.test.ts @@ -14,7 +14,7 @@ import { serveMutableOpenApiSpecTestServer, } from "@executor-js/plugin-openapi/testing"; -import { asOrg } from "./__test-harness__/api-harness"; +import { asOrg } from "../testing/api-harness"; const PingEndpoint = HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }); const PongEndpoint = HttpApiEndpoint.get("pong", "/pong", { success: Schema.Unknown }); diff --git a/apps/cloud/src/services/tenant-isolation.node.test.ts b/apps/cloud/src/api/tenant-isolation.node.test.ts similarity index 99% rename from apps/cloud/src/services/tenant-isolation.node.test.ts rename to apps/cloud/src/api/tenant-isolation.node.test.ts index b1c850db6..da2e5b8d2 100644 --- a/apps/cloud/src/services/tenant-isolation.node.test.ts +++ b/apps/cloud/src/api/tenant-isolation.node.test.ts @@ -9,7 +9,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk"; import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; -import { asOrg } from "./__test-harness__/api-harness"; +import { asOrg } from "../testing/api-harness"; const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add( HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), diff --git a/apps/cloud/src/app-paths.test.ts b/apps/cloud/src/app-paths.test.ts new file mode 100644 index 000000000..5b76d2fb9 --- /dev/null +++ b/apps/cloud/src/app-paths.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { isAppOwnedPath } 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 +// own routes must NOT be. The billing proxy + Swagger live under `/api` +// (`/api/billing/*`, `/api/docs`) — the React app posts to `/api/billing/*` via +// — so a request there must reach the handler, not the SPA. +describe("isAppOwnedPath", () => { + const appOwned = [ + "/api", + "/api/executions", + "/api/auth/me", + "/api/openapi.json", + "/api/billing/customer", // AutumnProvider pathPrefix — the billing UI + "/api/billing/attach", + "/api/docs", // Swagger UI + "/mcp", + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-authorization-server", + ]; + for (const pathname of appOwned) { + it(`forwards ${pathname} to the app handler`, () => { + expect(isAppOwnedPath(pathname)).toBe(true); + }); + } + + // Start-owned: the React shell + its routes. Note `/billing` (the React page) + // is distinct from `/api/billing/*` (the proxy) — only the latter is app-owned. + const startOwned = ["/", "/policies", "/login", "/billing", "/org", "/assets/app.js"]; + for (const pathname of startOwned) { + it(`leaves ${pathname} to the Start router`, () => { + expect(isAppOwnedPath(pathname)).toBe(false); + }); + } +}); diff --git a/apps/cloud/src/app-paths.ts b/apps/cloud/src/app-paths.ts new file mode 100644 index 000000000..6dd6fc56f --- /dev/null +++ b/apps/cloud/src/app-paths.ts @@ -0,0 +1,19 @@ +import { classifyMcpPath } from "./mcp/mount"; + +// --------------------------------------------------------------------------- +// Single source of truth for "does the unified app handler own this path?" — +// the decision `start.ts` makes per request (app handler vs TanStack Start). +// +// The app handler (`ExecutorApp.make`'s `toWebHandler`) serves everything under +// `/api/*` — the typed API plus the cloud `extensions.routes` (the Autumn billing +// proxy at `/api/billing/*` and Swagger at `/api/docs` both live under `/api`) — +// plus the `/mcp` serving envelope and its `/.well-known/*` OAuth discovery docs. +// The dispatcher forwards those UNMODIFIED; anything else falls through to the +// Start router. Keeping every served route under `/api` (no separate top-level +// namespace) is what keeps this gate a simple two-prefix check. +// --------------------------------------------------------------------------- + +export const isApiPath = (pathname: string) => pathname === "/api" || pathname.startsWith("/api/"); + +export const isAppOwnedPath = (pathname: string) => + isApiPath(pathname) || classifyMcpPath(pathname) !== null; diff --git a/apps/cloud/src/app.ts b/apps/cloud/src/app.ts new file mode 100644 index 000000000..9ae2bd124 --- /dev/null +++ b/apps/cloud/src/app.ts @@ -0,0 +1,139 @@ +import { Layer } from "effect"; +import { HttpServer } from "effect/unstable/http"; + +import { DbProvider, ExecutorApp } from "@executor-js/api/server"; + +import { cloudPlugins } from "./plugins"; +import { CoreSharedServices } from "./auth/workos"; +import { makeCloudExtensionRoutes } from "./extensions/routes"; +import { RequestScopedServicesLive } from "./api/layers"; +import { CloudMeteringEngineDecorator } from "./engine/execution-stack-metered"; +import { workosAccountMiddleware } from "./account/account-api"; +import { ApiKeyService } from "./auth/api-keys"; +import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider"; +import { DbService } from "./db/db"; +import { cloudMcpAuth, cloudMcpReporter, cloudMcpSessions } from "./mcp"; +import { McpSessionDO } from "./mcp/session-durable-object"; +import { ErrorCaptureLive } from "./observability"; +import { AutumnService } from "./extensions/billing/service"; +import { + CloudCodeExecutorProvider, + CloudDbProvider, + CloudHostConfig, + CloudPluginsProvider, +} from "./engine/execution-stack"; +import { WorkerTelemetryLive } from "./observability/telemetry"; + +// =========================================================================== +// The Executor CLOUD app, as ONE `ExecutorApp.make` call. +// +// The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed- +// session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket, +// the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object +// session store (the DO surfaced via `config.mcpExport`), 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 +// `apps/host-selfhost/src/app.ts` is the entire product difference. +// +// `ExecutorApp.make` owns the assembly (the 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 slots cloud's +// Pass-6 provider Layers into the named seams. +// +// Request scoping (Cloudflare Workers' I/O isolation): the postgres.js socket +// MUST be rebuilt per request. `requestScoped` is folded by `make` into the +// execution-stack middleware; the account + session extension routes fold their +// own `requestScopedMiddleware`. `boot` holds only long-lived context (WorkOS +// client, telemetry, billing service shell, the resolved identity provider). +// =========================================================================== + +// The WorkOS control plane: the raw SDK client (`CoreSharedServices`) is the +// base; the api-key service builds on it, so each WorkOS-dependent service shares +// the one boot `WorkOSClient`. Surfaces both tags (the api-key service is read by +// the account provider + MCP seam, AND by the per-request identity layer below). +// Lives in `boot`, so `workosIdentityLayer`'s residual `WorkOSClient | +// ApiKeyService` (the long-lived control plane it reads) resolves from there. +const apiKeyService = ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices)); +const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); + +// `CloudDbProvider` only reads the per-request `DbService` at runtime; we widen +// its residual type to also carry the boot `AutumnService` the metering +// decorator reads, so `make` infers `RDb = DbService | AutumnService` (both +// satisfied by `boot`, `DbService` per request via `requestScoped`). +const cloudDb: Layer.Layer = CloudDbProvider; + +const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ + plugins: cloudPlugins, + providers: { + // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS + // sealed-session cookie (precedence inside `workosIdentityLayer`). Maps + // rejected credentials to the shared `Unauthorized | NoOrganization | + // Unavailable`; cloud's failure strategy renders the exact `{ error, code }` + // JSON bytes at 401/403/503. The facade builds `authenticate` from the + // `IdentityProvider` tag and provides THIS layer per request over + // `requestScoped`, so the identity resolution lives in the request fiber's + // socket scope. Its residual `UserStoreService` resolves from `requestScoped` + // (the per-request socket); `WorkOSClient | ApiKeyService` from `boot`. + identity: workosIdentityLayer, + // The WorkOS account API (me / api-keys / org), built per request so the + // service closes over the per-request postgres socket; carries the Autumn + // seat-gate. Self-combines `requestScopedMiddleware`. + account: workosAccountMiddleware(RequestScopedServicesLive), + db: cloudDb, + engine: { + codeExecutor: CloudCodeExecutorProvider, + // Billing-as-extension #1: the usage-metering decorator (reads AutumnService). + decorator: CloudMeteringEngineDecorator, + }, + mcp: { + auth: cloudMcpAuth, + sessions: cloudMcpSessions, + reporter: cloudMcpReporter, + }, + plugins: { provider: CloudPluginsProvider, config: CloudHostConfig }, + errorCapture: ErrorCaptureLive, + }, + extensions: { + // Cloud's app-only HTTP surface: WorkOS session routes, domain-verification, + // Swagger/OpenAPI, the Autumn billing proxy, request-failure logging. + routes: makeCloudExtensionRoutes(RequestScopedServicesLive), + }, + config: { + mountPrefix: "/api", + // Cloud renders the shared identity errors as its exact `{ error, code }` + // JSON at 401/403/503 (byte-identical to the old `HttpResponseError` bodies). + failure: cloudIdentityFailureStrategy, + // The MCP session Durable Object class — a top-level Workers export a Layer + // can't return; surfaced so `server.ts` can re-export it. + mcpExport: McpSessionDO, + }, + // The long-lived (boot-scoped) context provideMerge'd under everything: the + // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request + // identity layer reads residually), billing's service shell (read by the + // metered decorator + free-limit gate), the worker tracer, and the HTTP + // platform. A boot-time WorkOS misconfig is unrecoverable -> `orDie`. + boot: controlPlane.pipe( + Layer.merge( + Layer.mergeAll(WorkerTelemetryLive, HttpServer.layerServices, AutumnService.Default), + ), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a boot-time WorkOS misconfiguration is unrecoverable + Layer.orDie, + ), + // Per request: the postgres socket (`DbService` / `UserStoreService`). The facade + // provide-merges `providers.identity` over THIS layer, so the neutral + // `IdentityProvider` is rebuilt per request in the same fiber scope as the socket + // it reads (Cloudflare Workers' I/O isolation) — the identity layer's per-request + // `UserStoreService` is covered by this layer (its `WorkOSClient | ApiKeyService` + // by `boot`). + requestScoped: RequestScopedServicesLive, +}); + +export { McpSessionDO }; + +export const CloudAppLayer = appLayer; +export const cloudMcpExport = mcpExport; + +// The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs), +// /mcp, /.well-known/* — everything the worker dispatches. +export const cloudApiHandler = toWebHandler; diff --git a/apps/cloud/src/auth/api-key-errors.ts b/apps/cloud/src/auth/api-key-errors.ts deleted file mode 100644 index 6a90e5318..000000000 --- a/apps/cloud/src/auth/api-key-errors.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Schema } from "effect"; - -export class ApiKeyManagementError extends Schema.TaggedErrorClass()( - "ApiKeyManagementError", - { cause: Schema.Unknown }, - { httpApiStatus: 500 }, -) {} diff --git a/apps/cloud/src/auth/api-keys.node.test.ts b/apps/cloud/src/auth/api-keys.node.test.ts index 319948871..6208dae37 100644 --- a/apps/cloud/src/auth/api-keys.node.test.ts +++ b/apps/cloud/src/auth/api-keys.node.test.ts @@ -2,15 +2,15 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { ApiKeyService } from "./api-keys"; -import { WorkOSAuth, type WorkOSAuthService } from "./workos"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; -const stubWorkOS = (overrides: Partial) => +const stubWorkOS = (overrides: Partial) => Layer.succeed( - WorkOSAuth, - new Proxy({} as WorkOSAuthService, { + WorkOSClient, + new Proxy({} as WorkOSClientService, { get: (_target, prop) => { - if (prop in overrides) return overrides[prop as keyof WorkOSAuthService]; - return () => Effect.die(`unexpected WorkOSAuth.${String(prop)} call`); + if (prop in overrides) return overrides[prop as keyof WorkOSClientService]; + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), ); diff --git a/apps/cloud/src/auth/api-keys.test-layer.ts b/apps/cloud/src/auth/api-keys.test-layer.ts index e190ce520..26d738308 100644 --- a/apps/cloud/src/auth/api-keys.test-layer.ts +++ b/apps/cloud/src/auth/api-keys.test-layer.ts @@ -1,7 +1,7 @@ import { Effect, Layer } from "effect"; import { ApiKeyService } from "./api-keys"; -import { ApiKeyManagementError } from "./api-key-errors"; +import { ApiKeyManagementError } from "./errors"; export const ApiKeyServiceTestLayer = Layer.succeed(ApiKeyService)({ validate: () => Effect.succeed(null), diff --git a/apps/cloud/src/auth/api-keys.ts b/apps/cloud/src/auth/api-keys.ts index f60928553..2984f5987 100644 --- a/apps/cloud/src/auth/api-keys.ts +++ b/apps/cloud/src/auth/api-keys.ts @@ -1,9 +1,11 @@ import { Context, Data, Effect, Layer, Option, Schema } from "effect"; -import { ApiKeyManagementError } from "./api-key-errors"; -import { WorkOSAuth } from "./workos"; +import { ApiKeyManagementError } from "./errors"; +import { WorkOSClient } from "./workos"; -export type ApiKeyPrincipal = { +/** The owner an api key resolves to — NOT a full {@link Principal} (no email / + * name / roles), so it carries an honest, distinct name. */ +export type ApiKeyOwner = { readonly accountId: string; readonly organizationId: string; readonly keyId: string; @@ -80,7 +82,7 @@ const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyRe const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse); const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse); -const principalFromResponse = (value: unknown): ApiKeyPrincipal | null => +const ownerFromResponse = (value: unknown): ApiKeyOwner | null => Option.match(decodeValidateApiKeyResponse(value), { onNone: () => null, onSome: ({ apiKey }) => { @@ -132,9 +134,7 @@ const createdFromResponse = (value: unknown): CreatedApiKey | null => export class ApiKeyService extends Context.Service< ApiKeyService, { - readonly validate: ( - value: string, - ) => Effect.Effect; + readonly validate: (value: string) => Effect.Effect; readonly listUserKeys: (input: { readonly accountId: string; readonly organizationId: string; @@ -151,11 +151,11 @@ export class ApiKeyService extends Context.Service< >()("@executor-js/cloud/ApiKeyService") { static WorkOS = Layer.effect(this)( Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; return { validate: (value: string) => workos.validateApiKey(value).pipe( - Effect.map(principalFromResponse), + Effect.map(ownerFromResponse), Effect.mapError((cause) => new ApiKeyValidationError({ cause })), ), listUserKeys: ({ accountId, organizationId }) => diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 912488ed5..973ade561 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -1,8 +1,8 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ApiKeyManagementError } from "./api-key-errors"; import { UserStoreError, WorkOSError } from "./errors"; -import { NoOrganization, SessionAuth } from "./middleware"; +import { NoOrganization } from "@executor-js/api/server"; +import { SessionAuth } from "./middleware"; const AuthUser = Schema.Struct({ id: Schema.String, @@ -78,35 +78,6 @@ const AcceptInvitationResponse = Schema.Struct({ name: Schema.String, }); -const ApiKeySummary = Schema.Struct({ - id: Schema.String, - name: Schema.String, - obfuscatedValue: Schema.String, - createdAt: Schema.String, - updatedAt: Schema.String, - lastUsedAt: Schema.NullOr(Schema.String), -}); - -const ApiKeysResponse = Schema.Struct({ - apiKeys: Schema.Array(ApiKeySummary), -}); - -const CreateApiKeyBody = Schema.Struct({ - name: Schema.String, -}); - -const CreatedApiKeyResponse = Schema.Struct({ - id: Schema.String, - name: Schema.String, - obfuscatedValue: Schema.String, - createdAt: Schema.String, - updatedAt: Schema.String, - lastUsedAt: Schema.NullOr(Schema.String), - value: Schema.String, -}); - -const ApiKeyParams = { apiKeyId: Schema.String }; - const McpSessionExecutionParams = { mcpSessionId: Schema.String, executionId: Schema.String, @@ -164,7 +135,6 @@ export const AUTH_PATHS = { } as const; const AuthErrors = [UserStoreError, WorkOSError] as const; -const ApiKeyErrors = [ApiKeyManagementError, NoOrganization, UserStoreError, WorkOSError] as const; const McpApprovalErrors = [ NoOrganization, McpExecutionNotFoundError, @@ -222,25 +192,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") error: AuthErrors, }), ) - .add( - HttpApiEndpoint.get("listApiKeys", "/auth/api-keys", { - success: ApiKeysResponse, - error: ApiKeyErrors, - }), - ) - .add( - HttpApiEndpoint.post("createApiKey", "/auth/api-keys", { - payload: CreateApiKeyBody, - success: CreatedApiKeyResponse, - error: ApiKeyErrors, - }), - ) - .add( - HttpApiEndpoint.delete("revokeApiKey", "/auth/api-keys/:apiKeyId", { - params: ApiKeyParams, - error: ApiKeyErrors, - }), - ) .add( HttpApiEndpoint.get("getMcpPaused", "/mcp-sessions/:mcpSessionId/executions/:executionId", { params: McpSessionExecutionParams, diff --git a/apps/cloud/src/services/auth-tool-failures.node.test.ts b/apps/cloud/src/auth/auth-tool-failures.node.test.ts similarity index 97% rename from apps/cloud/src/services/auth-tool-failures.node.test.ts rename to apps/cloud/src/auth/auth-tool-failures.node.test.ts index 37f1b8a48..87f10caa4 100644 --- a/apps/cloud/src/services/auth-tool-failures.node.test.ts +++ b/apps/cloud/src/auth/auth-tool-failures.node.test.ts @@ -19,7 +19,7 @@ import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/un import { ScopeId } from "@executor-js/sdk"; import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; -import { ProtectedCloudApi, asOrg } from "./__test-harness__/api-harness"; +import { ProtectedCloudApi, asOrg } from "../testing/api-harness"; const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add( HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), diff --git a/apps/cloud/src/auth/authorize-organization.ts b/apps/cloud/src/auth/authorize-organization.ts deleted file mode 100644 index a73fd4dfa..000000000 --- a/apps/cloud/src/auth/authorize-organization.ts +++ /dev/null @@ -1,37 +0,0 @@ -// --------------------------------------------------------------------------- -// Organization authorization — live membership check against WorkOS. -// --------------------------------------------------------------------------- -// -// The sealed session cookie carries an organizationId that WorkOS signed at -// login / refresh time. WorkOS does NOT invalidate existing sessions when a -// membership is revoked, and `session.authenticate()` validates the JWT -// locally without hitting the API — so a removed user keeps full access -// until their access token naturally expires (~10 min). -// -// To close that gap we verify membership live on every protected request. -// `listUserMemberships` is one WorkOS call per request. If this becomes a -// hot path we can layer a short per-(user, org) TTL cache underneath, or -// swap it for a local memberships table fed by the WorkOS Events API. -// -// Returns the resolved organization (via resolveOrganization) if the user -// currently holds an *active* membership in it, otherwise null. Callers -// should treat null as "no access" and route accordingly (onboarding page / -// 403). - -import { Effect } from "effect"; - -import { resolveOrganization } from "./resolve-organization"; -import { WorkOSAuth } from "./workos"; - -export const authorizeOrganization = (userId: string, organizationId: string) => - Effect.gen(function* () { - const workos = yield* WorkOSAuth; - const memberships = yield* workos.listUserMemberships(userId); - const active = memberships.data.find( - (m: { readonly organizationId: string; readonly status: string }) => - m.organizationId === organizationId && m.status === "active", - ); - if (!active) return null; - - return yield* resolveOrganization(organizationId); - }); diff --git a/apps/cloud/src/auth/bearer.ts b/apps/cloud/src/auth/bearer.ts new file mode 100644 index 000000000..ab0a8cfaf --- /dev/null +++ b/apps/cloud/src/auth/bearer.ts @@ -0,0 +1,9 @@ +// --------------------------------------------------------------------------- +// Bearer token parsing — single-sourced HTTP `Authorization: Bearer …` prefix. +// +// Shared by every cloud credential path that splits a bearer token off the +// `Authorization` header (the WorkOS api-key/session resolver and the MCP edge +// auth). Defined once so the literal cannot drift. +// --------------------------------------------------------------------------- + +export const BEARER_PREFIX = "Bearer "; diff --git a/apps/cloud/src/auth/cloud-auth-api.test-context.ts b/apps/cloud/src/auth/cloud-auth-api.test-context.ts index c81d6377e..8beb69b02 100644 --- a/apps/cloud/src/auth/cloud-auth-api.test-context.ts +++ b/apps/cloud/src/auth/cloud-auth-api.test-context.ts @@ -7,7 +7,7 @@ import { AutumnTestLayer, makeAutumnTestState, type AutumnTestState, -} from "../services/autumn.test-layer"; +} from "../extensions/billing/service.test-layer"; import { ApiKeyServiceTestLayer } from "./api-keys.test-layer"; import { makeUserStoreTestState, diff --git a/apps/cloud/src/auth/context.ts b/apps/cloud/src/auth/context.ts index 0734e46f6..e76272592 100644 --- a/apps/cloud/src/auth/context.ts +++ b/apps/cloud/src/auth/context.ts @@ -1,11 +1,8 @@ import { Context, Effect, Layer } from "effect"; -import { makeUserStore } from "../services/user-store"; -import { DbService } from "../services/db"; +import { makeUserStore } from "../auth/user-store"; +import { DbService } from "../db/db"; import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors"; -// AuthContext is defined in ./middleware.ts to keep middleware-related types together. -export { AuthContext } from "./middleware"; - // --------------------------------------------------------------------------- // UserStoreService — wraps the Drizzle-backed user store with Effect // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/auth/create-organization.e2e.node.test.ts b/apps/cloud/src/auth/create-organization.e2e.node.test.ts index 8685dd7c4..8cdaedd57 100644 --- a/apps/cloud/src/auth/create-organization.e2e.node.test.ts +++ b/apps/cloud/src/auth/create-organization.e2e.node.test.ts @@ -7,7 +7,7 @@ import { makeCloudAuthApiTestState, } from "./cloud-auth-api.test-context"; import { makeWorkOSTestMembership, makeWorkOSTestState } from "./workos.test-layer"; -import { makeAutumnTestState } from "../services/autumn.test-layer"; +import { makeAutumnTestState } from "../extensions/billing/service.test-layer"; describe("create organization API", () => { it.effect("lets a paid user create another organization through the HTTP API client", () => { diff --git a/apps/cloud/src/auth/errors.ts b/apps/cloud/src/auth/errors.ts index 36e48c0b4..775916ce1 100644 --- a/apps/cloud/src/auth/errors.ts +++ b/apps/cloud/src/auth/errors.ts @@ -12,6 +12,12 @@ export class WorkOSError extends Schema.TaggedErrorClass()( { httpApiStatus: 500 }, ) {} +export class ApiKeyManagementError extends Schema.TaggedErrorClass()( + "ApiKeyManagementError", + { cause: Schema.Unknown }, + { httpApiStatus: 500 }, +) {} + /** * Private wrapper used by service adapters that lift Promise APIs into * Effect. `withServiceLogging` immediately remaps these into a public-facing diff --git a/apps/cloud/src/auth/handlers.node.test.ts b/apps/cloud/src/auth/handlers.node.test.ts index 108c2385f..8ba2c69ea 100644 --- a/apps/cloud/src/auth/handlers.node.test.ts +++ b/apps/cloud/src/auth/handlers.node.test.ts @@ -8,12 +8,12 @@ import { CloudAuthPublicApi } from "./api"; import { CloudAuthPublicHandlers } from "./handlers"; import { UserStoreService } from "./context"; import { WorkOSError } from "./errors"; -import { WorkOSAuth } from "./workos"; +import { WorkOSClient } from "./workos"; const TestAuthPublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi); type EffectSuccess = T extends EffectType ? A : never; type AuthenticateWithCodeResult = EffectSuccess< - ReturnType + ReturnType >; const fakeUser: AuthenticateWithCodeResult["user"] = { object: "user", @@ -35,10 +35,10 @@ class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{ method: string; }> {} -const makeAuthFetch = (workos: Partial) => { +const makeAuthFetch = (workos: Partial) => { const WorkOSTest = Layer.succeed( - WorkOSAuth, - new Proxy(workos as WorkOSAuth["Service"], { + WorkOSClient, + new Proxy(workos as WorkOSClient["Service"], { get: (target, prop) => { if (prop in target) return target[prop as keyof typeof target]; return () => @@ -202,7 +202,7 @@ describe("Auth callback handlers", () => { status: "pending", }, ], - })) as unknown as WorkOSAuth["Service"]["listUserMemberships"], + })) as unknown as WorkOSClient["Service"]["listUserMemberships"], refreshSession: () => Effect.sync(() => { refreshCalls++; @@ -250,9 +250,9 @@ describe("Auth callback handlers", () => { status: "active", }, ], - })) as unknown as WorkOSAuth["Service"]["listUserMemberships"], + })) as unknown as WorkOSClient["Service"]["listUserMemberships"], refreshSession: (() => - Effect.fail(new WorkOSError())) as WorkOSAuth["Service"]["refreshSession"], + Effect.fail(new WorkOSError())) as WorkOSClient["Service"]["refreshSession"], }); const response = yield* Effect.promise(() => diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index a48914559..5b9e12f0c 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -1,7 +1,6 @@ import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerResponse } from "effect/unstable/http"; import { Duration, Effect, Predicate } from "effect"; -import { setCookie, deleteCookie } from "@tanstack/react-start/server"; import { AUTH_PATHS, @@ -10,21 +9,23 @@ import { McpExecutionNotFoundError, McpSessionForbiddenError, } from "./api"; -import { NoOrganization, SessionContext } from "./middleware"; +import { NoOrganization } from "@executor-js/api/server"; +import { SessionContext, SessionCookies } from "./middleware"; import { UserStoreService } from "./context"; -import { authorizeOrganization } from "./authorize-organization"; import { env } from "cloudflare:workers"; -import { ApiKeyManagementError } from "./api-key-errors"; import { WorkOSError } from "./errors"; -import { WorkOSAuth } from "./workos"; -import { ApiKeyService } from "./api-keys"; -import { AutumnService } from "../services/autumn"; +import { WorkOSClient } from "./workos"; +import { AutumnService } from "../extensions/billing/service"; import { hasPaidOrganizationSubscription, isOverFreeOrganizationLimit, shouldApplyFreeOrganizationLimit, -} from "./organization-limits"; -import type { McpSessionApprovalResult, McpSessionResumeApprovalResult } from "../mcp-session"; +} from "../extensions/billing/plans"; +import { authorizeOrganization } from "./organization"; +import type { + McpSessionApprovalResult, + McpSessionResumeApprovalResult, +} from "../mcp/session-durable-object"; const COOKIE_OPTIONS = { path: "/", @@ -62,8 +63,6 @@ const DELETE_COOKIE_OPTIONS = { secure: true, }; -const MAX_API_KEY_NAME_LENGTH = 80; - const randomState = (): string => { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); @@ -79,16 +78,6 @@ const timingSafeEqual = (a: string, b: string): boolean => { return diff === 0; }; -const requireSessionOrganization = Effect.gen(function* () { - const session = yield* SessionContext; - if (!session.organizationId) { - return yield* new NoOrganization(); - } - const org = yield* authorizeOrganization(session.accountId, session.organizationId); - if (!org) return yield* new NoOrganization(); - return { session, org }; -}); - const requireSessionOrganizationId = Effect.gen(function* () { const session = yield* SessionContext; if (!session.organizationId) { @@ -156,7 +145,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( handlers .handleRaw("login", () => Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; // Use the explicit public site URL — in dev, the request's Host // header points at the internal proxy target, not the public URL // WorkOS needs to redirect back to. @@ -173,7 +162,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( ) .handleRaw("callback", ({ request, query }) => Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const users = yield* UserStoreService; const cookieState = request.cookies[STATE_COOKIE] ?? null; // CSRF check is only enforced when the redirect carries a state @@ -262,13 +251,14 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( }; }), ) - .handleRaw("logout", () => { - deleteCookie("wos-session", { path: "/" }); - return Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })); - }) + .handleRaw("logout", () => + Effect.succeed( + deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"), + ), + ) .handle("organizations", () => Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const session = yield* SessionContext; const memberships = yield* workos.listUserMemberships(session.accountId); @@ -290,7 +280,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( ) .handle("switchOrganization", ({ payload }) => Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const session = yield* SessionContext; const refreshed = yield* workos.refreshSession( @@ -298,13 +288,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( payload.organizationId, ); if (refreshed) { - setCookie("wos-session", refreshed, COOKIE_OPTIONS); + (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); } }), ) .handle("createOrganization", ({ payload }) => Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const users = yield* UserStoreService; const session = yield* SessionContext; const autumn = yield* AutumnService; @@ -365,17 +355,17 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( verifiedOrgId: verified?.organizationId ?? null, }, ); - deleteCookie("wos-session", { path: "/" }); + (yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS); return yield* new WorkOSError(); } - setCookie("wos-session", refreshed, COOKIE_OPTIONS); + (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); return { id: org.id, name: org.name }; }), ) .handle("pendingInvitations", () => Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const session = yield* SessionContext; const invitations = yield* workos.listInvitationsByEmail(session.email); @@ -424,7 +414,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( ) .handle("acceptInvitation", ({ payload }) => Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const users = yield* UserStoreService; const session = yield* SessionContext; @@ -454,45 +444,18 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( if (!refreshed || !verified || verified.organizationId !== org.id) { yield* Effect.logWarning("acceptInvitation: unable to attach org to current session", { userId: session.accountId, - orgId: org.id, + organizationId: org.id, refreshReturnedSession: refreshed != null, verifiedOrgId: verified?.organizationId ?? null, }); - deleteCookie("wos-session", { path: "/" }); + (yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS); return yield* new WorkOSError(); } - setCookie("wos-session", refreshed, COOKIE_OPTIONS); + (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); return { id: org.id, name: org.name }; }), ) - .handle("listApiKeys", () => - Effect.gen(function* () { - const { session, org } = yield* requireSessionOrganization; - const apiKeys = yield* ApiKeyService; - const keys = yield* apiKeys.listUserKeys({ - accountId: session.accountId, - organizationId: org.id, - }); - return { apiKeys: keys }; - }), - ) - .handle("createApiKey", ({ payload }) => - Effect.gen(function* () { - const { session, org } = yield* requireSessionOrganization; - const name = payload.name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); - if (!name) { - return yield* new ApiKeyManagementError({ cause: "missing_name" }); - } - - const apiKeys = yield* ApiKeyService; - return yield* apiKeys.createUserKey({ - accountId: session.accountId, - organizationId: org.id, - name, - }); - }), - ) .handle("getMcpPaused", ({ params }) => Effect.gen(function* () { const owner = yield* requireSessionOrganizationId; @@ -553,19 +516,5 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( isError: result.isError ?? false, }; }), - ) - .handle("revokeApiKey", ({ params }) => - Effect.gen(function* () { - const { session, org } = yield* requireSessionOrganization; - const apiKeys = yield* ApiKeyService; - const ownedKeys = yield* apiKeys.listUserKeys({ - accountId: session.accountId, - organizationId: org.id, - }); - if (!ownedKeys.some((key) => key.id === params.apiKeyId)) { - return yield* new ApiKeyManagementError({ cause: "api_key_not_found" }); - } - yield* apiKeys.revokeUserKey({ keyId: params.apiKeyId }); - }), ), ); diff --git a/apps/cloud/src/jwks-cache.node.test.ts b/apps/cloud/src/auth/jwks-cache.node.test.ts similarity index 100% rename from apps/cloud/src/jwks-cache.node.test.ts rename to apps/cloud/src/auth/jwks-cache.node.test.ts diff --git a/apps/cloud/src/jwks-cache.ts b/apps/cloud/src/auth/jwks-cache.ts similarity index 100% rename from apps/cloud/src/jwks-cache.ts rename to apps/cloud/src/auth/jwks-cache.ts diff --git a/apps/cloud/src/auth/middleware-live.ts b/apps/cloud/src/auth/middleware-live.ts index 62490b337..16ac3a4ff 100644 --- a/apps/cloud/src/auth/middleware-live.ts +++ b/apps/cloud/src/auth/middleware-live.ts @@ -4,21 +4,25 @@ // --------------------------------------------------------------------------- import { Effect, Layer, Redacted } from "effect"; +import { HttpServerResponse } from "effect/unstable/http"; + +import { AuthContext, NoOrganization, Unauthorized } from "@executor-js/api/server"; import { - AuthContext, - NoOrganization, OrgAuth, SessionAuth, SessionContext, - Unauthorized, + SessionCookies, + sessionFromSealed, + type SessionCookieOptions, + type SessionCookieSetter, } from "./middleware"; -import { WorkOSAuth } from "./workos"; +import { WorkOSClient } from "./workos"; export const SessionAuthLive = Layer.effect( SessionAuth, Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; return { cookie: (httpEffect, { credential }) => Effect.gen(function* () { @@ -30,17 +34,33 @@ export const SessionAuthLive = Layer.effect( return yield* Effect.fail(new Unauthorized()); } - const session = { - accountId: result.userId, - email: result.email, - name: `${result.firstName ?? ""} ${result.lastName ?? ""}`.trim() || null, - avatarUrl: result.avatarUrl ?? null, - organizationId: result.organizationId ?? null, - sealedSession: result.refreshedSession ?? Redacted.value(credential), - refreshedSession: result.refreshedSession ?? null, + // Per-request cookie queue. Typed `.handle()` session handlers (the + // WorkOS session-refresh on switchOrganization / createOrganization / + // acceptInvitation) return DATA, so they can't attach a Set-Cookie + // themselves — they `yield* SessionCookies` and queue writes, which we + // drain onto the response below. This is what lets `handlers.ts` drop + // the `@tanstack/react-start/server` `setCookie` import (the sole thing + // that pulled TanStack Start into the backend / Durable-Object graph). + const pending: Array<{ + readonly name: string; + readonly value: string; + readonly options: SessionCookieOptions; + }> = []; + const cookieSetter: SessionCookieSetter = { + set: (name, value, options) => { + pending.push({ name, value, options }); + }, }; - return yield* Effect.provideService(httpEffect, SessionContext, session); + const session = sessionFromSealed(result, Redacted.value(credential)); + const response = yield* httpEffect.pipe( + Effect.provideService(SessionContext, session), + Effect.provideService(SessionCookies, cookieSetter), + ); + return pending.reduce( + (res, c) => HttpServerResponse.setCookieUnsafe(res, c.name, c.value, c.options), + response, + ); }), }; }), @@ -49,7 +69,7 @@ export const SessionAuthLive = Layer.effect( export const OrgAuthLive = Layer.effect( OrgAuth, Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; return { cookie: (httpEffect, { credential }) => Effect.gen(function* () { @@ -65,12 +85,17 @@ export const OrgAuthLive = Layer.effect( return yield* Effect.fail(new NoOrganization()); } + const session = sessionFromSealed(result, Redacted.value(credential)); const auth = { - accountId: result.userId, + accountId: session.accountId, organizationId: result.organizationId, - email: result.email, - name: `${result.firstName ?? ""} ${result.lastName ?? ""}`.trim() || null, - avatarUrl: result.avatarUrl ?? null, + email: session.email, + name: session.name, + avatarUrl: session.avatarUrl, + // The unified `AuthContext` carries roles; cloud's WorkOS control + // plane does not resolve them here, so pass an empty list (no cloud + // handler reads roles today). + roles: [], }; return yield* Effect.provideService(httpEffect, AuthContext, auth); diff --git a/apps/cloud/src/auth/middleware.test-layer.ts b/apps/cloud/src/auth/middleware.test-layer.ts index 4502a7921..bff3fae79 100644 --- a/apps/cloud/src/auth/middleware.test-layer.ts +++ b/apps/cloud/src/auth/middleware.test-layer.ts @@ -1,6 +1,6 @@ import { Effect, Layer } from "effect"; -import { SessionAuth, SessionContext, type Session } from "./middleware"; +import { SessionAuth, SessionContext, SessionCookies, type Session } from "./middleware"; export type SessionTestContext = Session; @@ -19,5 +19,11 @@ export const makeSessionTestContext = ( export const SessionAuthTestLayer = (session: Session = makeSessionTestContext()) => Layer.succeed(SessionAuth)({ - cookie: (httpEffect) => Effect.provideService(httpEffect, SessionContext, session), + cookie: (httpEffect) => + httpEffect.pipe( + Effect.provideService(SessionContext, session), + // The session handlers driven via this layer don't assert cookie output; + // a no-op setter satisfies the SessionCookies the middleware provides. + Effect.provideService(SessionCookies, { set: () => {} }), + ), }); diff --git a/apps/cloud/src/auth/middleware.ts b/apps/cloud/src/auth/middleware.ts index 8a6409d56..cecd1a64f 100644 --- a/apps/cloud/src/auth/middleware.ts +++ b/apps/cloud/src/auth/middleware.ts @@ -5,13 +5,30 @@ // the SPA pulls in for typed schemas). // --------------------------------------------------------------------------- -import { Context, Schema } from "effect"; +import { Context } from "effect"; +import type { HttpServerResponse } from "effect/unstable/http"; import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"; +// The executor-API identity seam lives in `@executor-js/api/server`: the one +// `AuthContext` handlers read (carries roles) and the one `Unauthorized` / +// `NoOrganization` error pair (httpApiStatus 401 / 403), shared with self-host. +// These are the canonical tags; consumers import them from `@executor-js/api/server` +// directly. This module reads them to declare `SessionAuth` / `OrgAuth`. +import { AuthContext, NoOrganization, Unauthorized } from "@executor-js/api/server"; + // --------------------------------------------------------------------------- // Session — what every authenticated request gets // --------------------------------------------------------------------------- +// Cookie-write options — exactly the options `HttpServerResponse.setCookieUnsafe` +// accepts (derived so they can't drift; `import type` keeps this SPA-imported +// module free of any server runtime). The auth handlers hand over the same +// `RESPONSE_COOKIE_OPTIONS` / `DELETE_COOKIE_OPTIONS` constants the existing +// `setResponseCookie` path uses, so the emitted `Set-Cookie` bytes are identical. +export type SessionCookieOptions = NonNullable< + Parameters[3] +>; + export type Session = { readonly accountId: string; readonly email: string; @@ -27,29 +44,74 @@ export class SessionContext extends Context.Service()( "@executor-js/cloud/Session", ) {} -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- +// A request-scoped cookie setter, provided ALONGSIDE `SessionContext` by +// `SessionAuth` (see its `provides` below). Typed `.handle()` handlers — the +// WorkOS session-refresh on switchOrganization / createOrganization / +// acceptInvitation — return DATA, not an `HttpServerResponse`, so they can't +// attach a `Set-Cookie` directly. They `yield* SessionCookies` and queue writes; +// `SessionAuthLive` drains the queue onto the outgoing response. It's a SEPARATE +// service, not a field on `Session`, so the session DATA stays pure — `OrgAuth` +// and the account API build a `Session` but never write cookies, so they carry +// no writer. This replaces the old `@tanstack/react-start/server` `setCookie` +// import (the one thing that pulled TanStack Start into the backend graph). +export type SessionCookieSetter = { + /** Queue a `Set-Cookie` to apply to the response. */ + readonly set: (name: string, value: string, options: SessionCookieOptions) => void; +}; -export class Unauthorized extends Schema.TaggedErrorClass()( - "Unauthorized", - {}, - { httpApiStatus: 401 }, +export class SessionCookies extends Context.Service()( + "@executor-js/cloud/SessionCookies", ) {} -export class NoOrganization extends Schema.TaggedErrorClass()( - "NoOrganization", - {}, - { httpApiStatus: 403 }, -) {} +/** + * The authenticated result shape `WorkOSClient.authenticateSealedSession` / + * `authenticateRequest` yield. Structural so the mapper below stays a pure + * function with no WorkOS-SDK import (this module is in the SPA bundle). + */ +export type SealedSessionResult = { + readonly userId: string; + readonly email: string; + readonly firstName?: string | null; + readonly lastName?: string | null; + readonly avatarUrl?: string | null; + readonly organizationId?: string | null; + readonly refreshedSession?: string | undefined; +}; + +/** The display name WorkOS first/last fields collapse to, or `null`. */ +export const sealedSessionDisplayName = (result: SealedSessionResult): string | null => + `${result.firstName ?? ""} ${result.lastName ?? ""}`.trim() || null; + +/** + * The ONE sealed-session → {@link Session} mapper. `SessionAuthLive` and the + * account-API session middleware both build a `Session` from a verified + * sealed-session result; this folds their (previously inline, byte-identical) + * copies into one. `sealedSessionFallback` is the cookie value to keep as the + * `sealedSession` when WorkOS didn't hand back a refreshed one (the cookie for + * `SessionAuthLive`, `""` for the account API which never re-sets the cookie). + */ +export const sessionFromSealed = ( + result: SealedSessionResult, + sealedSessionFallback: string, +): Session => ({ + accountId: result.userId, + email: result.email, + name: sealedSessionDisplayName(result), + avatarUrl: result.avatarUrl ?? null, + organizationId: result.organizationId ?? null, + sealedSession: result.refreshedSession ?? sealedSessionFallback, + refreshedSession: result.refreshedSession ?? null, +}); // --------------------------------------------------------------------------- -// SessionAuth — resolves the WorkOS session cookie, provides SessionContext +// SessionAuth — resolves the WorkOS session cookie; provides SessionContext AND +// the SessionCookies setter (so a typed handler can queue a session-cookie +// refresh that SessionAuthLive applies to the response). // --------------------------------------------------------------------------- export class SessionAuth extends HttpApiMiddleware.Service< SessionAuth, - { provides: SessionContext } + { provides: SessionContext | SessionCookies } >()("SessionAuth", { error: Unauthorized, security: { @@ -58,20 +120,10 @@ export class SessionAuth extends HttpApiMiddleware.Service< }) {} // --------------------------------------------------------------------------- -// OrgAuth — like SessionAuth but rejects sessions with no organization +// OrgAuth — like SessionAuth but rejects sessions with no organization. +// Provides the shared `AuthContext` (re-exported above). // --------------------------------------------------------------------------- -export class AuthContext extends Context.Service< - AuthContext, - { - readonly accountId: string; - readonly organizationId: string; - readonly email: string; - readonly name: string | null; - readonly avatarUrl: string | null; - } ->()("@executor-js/cloud/AuthContext") {} - export class OrgAuth extends HttpApiMiddleware.Service()( "OrgAuth", { diff --git a/apps/cloud/src/auth/organization-limits.ts b/apps/cloud/src/auth/organization-limits.ts deleted file mode 100644 index e67164273..000000000 --- a/apps/cloud/src/auth/organization-limits.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES, - PAID_AUTUMN_PLAN_IDS, -} from "../services/autumn-plans"; - -export const FREE_ORGANIZATIONS_PER_USER_LIMIT = 3; - -export type OrganizationLimitSubscriptionSummary = { - readonly planId?: string | null; - readonly status?: string | null; -}; - -export type OrganizationLimitMembershipSummary = { - readonly organizationId: string; - readonly status?: string | null; -}; - -export const isPaidOrganizationSubscription = ( - subscription: OrganizationLimitSubscriptionSummary, -): boolean => - subscription.planId != null && - PAID_AUTUMN_PLAN_IDS.has(subscription.planId) && - ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""); - -export const hasPaidOrganizationSubscription = ( - subscriptions: ReadonlyArray, -): boolean => subscriptions.some(isPaidOrganizationSubscription); - -export const shouldApplyFreeOrganizationLimit = ( - activeMemberships: ReadonlyArray, - paidOrganizationIds: ReadonlySet, -): boolean => - !activeMemberships.some((membership) => paidOrganizationIds.has(membership.organizationId)); - -export const isOverFreeOrganizationLimit = ( - activeMemberships: ReadonlyArray, -): boolean => activeMemberships.length >= FREE_ORGANIZATIONS_PER_USER_LIMIT; diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts new file mode 100644 index 000000000..a63733d57 --- /dev/null +++ b/apps/cloud/src/auth/organization.ts @@ -0,0 +1,74 @@ +// --------------------------------------------------------------------------- +// Organization resolution + authorization. +// +// One module for the cloud org auth-resolution path: +// - `resolveOrganization` — local mirror with lazy WorkOS fallback. +// - `authorizeOrganization` — live membership check, returns the resolved org. +// +// Deliberately billing-FREE: this module is reached by the MCP session DO bundle +// (via `mcp/auth.ts`), which must not transitively import any billing config +// (`autumn.config` / `atmn`). The free-organizations-per-user limit predicates — +// which DO depend on the Autumn plan config — live in `extensions/billing/plans.ts`. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { UserStoreService } from "./context"; +import { WorkOSClient } from "./workos"; + +// --------------------------------------------------------------------------- +// Resolution — local mirror with lazy WorkOS fallback. +// --------------------------------------------------------------------------- +// +// We keep a minimal local mirror of organizations so domain tables can +// foreign-key against them and so we don't hit WorkOS on every request. +// But the mirror can drift: a user's session can reference an org that was +// created outside this app (or before the mirror existed). Rather than +// proactively mirroring on every login — which was the source of the messy +// callback flow we just untangled — we mirror lazily the first time an +// unknown org is read. All other callers just do `getOrganization` and get +// a self-healing lookup for free. + +export const resolveOrganization = (organizationId: string) => + Effect.gen(function* () { + const users = yield* UserStoreService; + const existing = yield* users.use((s) => s.getOrganization(organizationId)); + if (existing) return existing; + + const workos = yield* WorkOSClient; + const fresh = yield* workos.getOrganization(organizationId); + return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name })); + }); + +// --------------------------------------------------------------------------- +// Authorization — live membership check against WorkOS. +// --------------------------------------------------------------------------- +// +// The sealed session cookie carries an organizationId that WorkOS signed at +// login / refresh time. WorkOS does NOT invalidate existing sessions when a +// membership is revoked, and `session.authenticate()` validates the JWT +// locally without hitting the API — so a removed user keeps full access +// until their access token naturally expires (~10 min). +// +// To close that gap we verify membership live on every protected request. +// `listUserMemberships` is one WorkOS call per request. If this becomes a +// hot path we can layer a short per-(user, org) TTL cache underneath, or +// swap it for a local memberships table fed by the WorkOS Events API. +// +// Returns the resolved organization (via resolveOrganization) if the user +// currently holds an *active* membership in it, otherwise null. Callers +// should treat null as "no access" and route accordingly (onboarding page / +// 403). + +export const authorizeOrganization = (userId: string, organizationId: string) => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + const memberships = yield* workos.listUserMemberships(userId); + const active = memberships.data.find( + (m: { readonly organizationId: string; readonly status: string }) => + m.organizationId === organizationId && m.status === "active", + ); + if (!active) return null; + + return yield* resolveOrganization(organizationId); + }); diff --git a/apps/cloud/src/auth/resolve-organization.ts b/apps/cloud/src/auth/resolve-organization.ts deleted file mode 100644 index b0cd5fdc1..000000000 --- a/apps/cloud/src/auth/resolve-organization.ts +++ /dev/null @@ -1,28 +0,0 @@ -// --------------------------------------------------------------------------- -// Organization lookup — local mirror with lazy WorkOS fallback. -// --------------------------------------------------------------------------- -// -// We keep a minimal local mirror of organizations so domain tables can -// foreign-key against them and so we don't hit WorkOS on every request. -// But the mirror can drift: a user's session can reference an org that was -// created outside this app (or before the mirror existed). Rather than -// proactively mirroring on every login — which was the source of the messy -// callback flow we just untangled — we mirror lazily the first time an -// unknown org is read. All other callers just do `getOrganization` and get -// a self-healing lookup for free. - -import { Effect } from "effect"; - -import { UserStoreService } from "./context"; -import { WorkOSAuth } from "./workos"; - -export const resolveOrganization = (organizationId: string) => - Effect.gen(function* () { - const users = yield* UserStoreService; - const existing = yield* users.use((s) => s.getOrganization(organizationId)); - if (existing) return existing; - - const workos = yield* WorkOSAuth; - const fresh = yield* workos.getOrganization(organizationId); - return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name })); - }); diff --git a/apps/cloud/src/services/user-store.ts b/apps/cloud/src/auth/user-store.ts similarity index 94% rename from apps/cloud/src/services/user-store.ts rename to apps/cloud/src/auth/user-store.ts index d5a4781f8..8af265eff 100644 --- a/apps/cloud/src/services/user-store.ts +++ b/apps/cloud/src/auth/user-store.ts @@ -9,8 +9,8 @@ import { eq } from "drizzle-orm"; -import { accounts, organizations } from "./schema"; -import type { DrizzleDb } from "./db"; +import { accounts, organizations } from "../db/schema"; +import type { DrizzleDb } from "../db/db"; export type Account = typeof accounts.$inferSelect; export type Organization = typeof organizations.$inferSelect; diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts new file mode 100644 index 000000000..4bd5495f3 --- /dev/null +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -0,0 +1,224 @@ +// --------------------------------------------------------------------------- +// Cloud's identity provider — folds the three former `protected.ts` resolvers +// (`resolveApiKeyPrincipal`, `resolveSessionPrincipal`, `resolveProtectedPrincipal`) +// into one `authenticate(request)` that the shared `ExecutionStackMiddleware` +// consumes. The credential precedence (Bearer api-key BEATS sealed-session +// cookie) stays INSIDE this adapter — it is WorkOS-specific and deliberately not +// abstracted into the shared seam. +// +// Cloud now provides the NEUTRAL `IdentityProvider` tag (same as self-host), not +// a forked one. Each rejected path raises the SHARED identity error carrying the +// SAME machine `code` + `message` it always emitted, so cloud's failure strategy +// reproduces the exact `{ error, code }` JSON bytes at the SAME status: +// - non-Bearer header -> Unauthorized 401 invalid_authorization_header +// - empty Bearer token -> Unauthorized 401 invalid_api_key +// - api-key validate outage -> Unavailable 503 api_key_validation_unavailable +// - invalid api key -> Unauthorized 401 invalid_api_key +// - api-key org not authorized -> NoOrganization 403 no_organization +// - no/invalid session -> NoOrganization 403 no_organization +// - session org not authorized -> NoOrganization 403 no_organization +// - no auth header -> falls through to the sealed-session path +// The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are +// `Effect.die`d so they surface as 500 defects — the same status the old inline +// resolver produced when those bubbled up. +// +// The per-request `UserStoreService` (read by the org-resolution path) stays a +// REQUIREMENT OF THE LAYER, satisfied by the facade's per-request DB combine — +// NOT a function-level requirement (that is what forced a forked tag before). +// --------------------------------------------------------------------------- + +import { Effect, Layer } from "effect"; +import { HttpServerResponse } from "effect/unstable/http"; + +import { + IdentityProvider, + NoOrganization, + Unauthorized, + Unavailable, +} from "@executor-js/api/server"; +import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; + +import { ApiKeyService } from "./api-keys"; +import { BEARER_PREFIX } from "./bearer"; +import { authorizeOrganization } from "./organization"; +import { UserStoreService } from "./context"; +import { sealedSessionDisplayName } from "./middleware"; +import type { UserStoreError, WorkOSError } from "./errors"; +import { WorkOSClient } from "./workos"; + +// The exact machine codes + messages each rejected path has always emitted. +// Carried on the shared identity error so the failure strategy renders the +// byte-identical `{ error, code }` body. +const INVALID_AUTHORIZATION_HEADER = { + code: "invalid_authorization_header", + message: "Authorization header must use Bearer authentication", +}; +const INVALID_API_KEY = { code: "invalid_api_key", message: "Invalid API key" }; +const API_KEY_VALIDATION_UNAVAILABLE = { + code: "api_key_validation_unavailable", + message: "API key validation is temporarily unavailable", +}; +const NO_ORGANIZATION_IN_API_KEY = { + code: "no_organization", + message: "No organization in API key", +}; +const NO_ORGANIZATION_IN_SESSION = { + code: "no_organization", + message: "No organization in session", +}; + +export const resolveApiKeyPrincipal = (request: Request) => + Effect.gen(function* () { + const authHeader = request.headers.get("authorization"); + if (!authHeader) return null; + + if (!authHeader.startsWith(BEARER_PREFIX)) { + return yield* new Unauthorized(INVALID_AUTHORIZATION_HEADER); + } + + const value = authHeader.slice(BEARER_PREFIX.length).trim(); + if (!value) return yield* new Unauthorized(INVALID_API_KEY); + + const apiKeys = yield* ApiKeyService; + const principal = yield* apiKeys + .validate(value) + .pipe( + Effect.catchTag("ApiKeyValidationError", () => + Effect.fail(new Unavailable(API_KEY_VALIDATION_UNAVAILABLE)), + ), + ); + + if (!principal) return yield* new Unauthorized(INVALID_API_KEY); + + const org = yield* authorizeOrganization(principal.accountId, principal.organizationId); + if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); + + return { + accountId: principal.accountId, + organizationId: org.id, + organizationName: org.name, + email: "", + name: null, + avatarUrl: null, + roles: [], + } satisfies Principal; + }); + +export const resolveSessionPrincipal = (request: Request) => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + const session = yield* workos.authenticateRequest(request); + if (!session || !session.organizationId) { + return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); + } + const org = yield* authorizeOrganization(session.userId, session.organizationId); + if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); + return { + accountId: session.userId, + organizationId: org.id, + organizationName: org.name, + email: session.email, + name: sealedSessionDisplayName(session), + avatarUrl: session.avatarUrl ?? null, + roles: [], + } satisfies Principal; + }); + +/** + * Resolve to the neutral `Principal` (api-key BEATS sealed-session). Cloud has + * no roles to resolve, so each leaf already carries `roles: []`. Raises the + * SHARED identity errors directly (`Unauthorized | NoOrganization | Unavailable`, + * each carrying its machine `code` + `message`); the org-resolution infra errors + * (`UserStoreError` / `WorkOSError`) bubble for `workosIdentityLayer` to `die`. + * Keeps `WorkOSClient` / `ApiKeyService` / `UserStoreService` as requirements (the + * org-resolution path reads them) so it stays request-scoped. Re-exported for + * `protected-api-key-auth.node.test.ts`, which asserts the per-path principal + + * shared error codes this folded resolver emits. + */ +export const resolveProtectedPrincipal = ( + request: Request, +): Effect.Effect< + Principal, + Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, + WorkOSClient | ApiKeyService | UserStoreService +> => + Effect.gen(function* () { + const apiKeyPrincipal = yield* resolveApiKeyPrincipal(request); + if (apiKeyPrincipal) return apiKeyPrincipal; + return yield* resolveSessionPrincipal(request); + }); + +/** + * Cloud's NEUTRAL `IdentityProvider` Layer. Closes over the long-lived + * `WorkOSClient` + `ApiKeyService`; the request-scoped `UserStoreService` stays a + * REQUIREMENT OF THE LAYER, satisfied per request by the facade's DB combine. + * `authenticate` matches the neutral shape exactly (`Effect`): rejected credentials already + * carry the shared errors; the org-resolution infra errors (`UserStoreError` / + * `WorkOSError`) are `Effect.die`d so they surface as 500 defects, never on the + * error channel. + */ +export const workosIdentityLayer: Layer.Layer< + IdentityProvider, + never, + WorkOSClient | ApiKeyService | UserStoreService +> = Layer.effect( + IdentityProvider, + Effect.gen(function* () { + const context = yield* Effect.context(); + return IdentityProvider.of({ + authenticate: (request) => + resolveProtectedPrincipal(request).pipe( + // `UserStoreError` / `WorkOSError` are org-resolution infra failures — + // surface as a 500 defect, exactly as the old inline resolver let them + // bubble. The narrow `die` here is the runtime edge for that infra + // failure; the shared identity errors stay typed on the channel. + Effect.catchTags({ + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: org-resolution infra failure -> 500 defect, matches prior inline-resolver behavior + UserStoreError: (error) => Effect.die(error), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: org-resolution infra failure -> 500 defect, matches prior inline-resolver behavior + WorkOSError: (error) => Effect.die(error), + }), + Effect.provide(context), + ), + }); + }), +); + +// Render a shared identity failure as cloud's exact `{ error, code }` JSON body +// at the given status. `code` + `message` ride on the shared error (cloud always +// supplies both); the defaults only guard the self-host-produced bare errors. +const renderIdentityFailure = + (status: number, fallbackCode: string, fallbackMessage: string) => + (failure: { readonly code?: string; readonly message?: string }) => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { + error: failure.message ?? fallbackMessage, + code: failure.code ?? fallbackCode, + }, + { status }, + ), + ); + +/** + * Cloud's failure-rendering STRATEGY. Where self-host's `textFailureStrategy` + * renders the shared identity errors as plain text, cloud renders them as its + * exact `{ error, code }` JSON at 401 / 403 / 503 — BYTE-IDENTICAL to the old + * `HttpResponseError` responses. The `code` + `message` carried on each shared + * error reproduce the precise body; the tag fixes the status. + */ +export const cloudIdentityFailureStrategy: FailureRenderingStrategy = { + renderFailure: (effect) => + effect.pipe( + Effect.catchTags({ + Unauthorized: renderIdentityFailure(401, "unauthorized", "Unauthorized"), + NoOrganization: renderIdentityFailure(403, "no_organization", "No organization"), + Unavailable: renderIdentityFailure( + 503, + "service_unavailable", + "Service temporarily unavailable", + ), + }), + ), +}; diff --git a/apps/cloud/src/auth/workos.test-layer.ts b/apps/cloud/src/auth/workos.test-layer.ts index d2d7f97da..6b111367d 100644 --- a/apps/cloud/src/auth/workos.test-layer.ts +++ b/apps/cloud/src/auth/workos.test-layer.ts @@ -1,7 +1,7 @@ import { Data, Effect, Layer } from "effect"; import type { Organization, OrganizationMembership, OrganizationRole } from "@workos-inc/node"; -import { WorkOSAuth, type WorkOSCollectedList } from "./workos"; +import { WorkOSClient, type WorkOSCollectedList } from "./workos"; export type WorkOSTestState = { readonly memberships: readonly OrganizationMembership[]; @@ -76,9 +76,9 @@ const collected = (data: readonly A[]): WorkOSCollectedList => ({ }, }); -const makeWorkOSTestService = (state: WorkOSTestState): WorkOSAuth["Service"] => { +const makeWorkOSTestService = (state: WorkOSTestState): WorkOSClient["Service"] => { const nextOrgId = "org_created"; - const service: Partial = { + const service: Partial = { listUserMemberships: () => Effect.succeed(collected(state.memberships)), createOrganization: (name) => Effect.sync(() => { @@ -105,7 +105,7 @@ const makeWorkOSTestService = (state: WorkOSTestState): WorkOSAuth["Service"] => }), }; - return new Proxy(service as WorkOSAuth["Service"], { + return new Proxy(service as WorkOSClient["Service"], { get: (target, prop) => { if (prop in target) return target[prop as keyof typeof target]; return () => @@ -119,4 +119,4 @@ const makeWorkOSTestService = (state: WorkOSTestState): WorkOSAuth["Service"] => }; export const WorkOSTestLayer = (state: WorkOSTestState) => - Layer.succeed(WorkOSAuth)(makeWorkOSTestService(state)); + Layer.succeed(WorkOSClient)(makeWorkOSTestService(state)); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 1b539fe49..02b1ce385 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -106,7 +106,7 @@ export const collectRawWorkOSList = async ( }; }; -class WorkOSAuthConfigurationError extends Data.TaggedError("WorkOSAuthConfigurationError")<{ +class WorkOSConfigurationError extends Data.TaggedError("WorkOSConfigurationError")<{ readonly message: string; }> {} @@ -120,7 +120,7 @@ const make = Effect.gen(function* () { const cookiePassword = env.WORKOS_COOKIE_PASSWORD; if (!cookiePassword || cookiePassword.length < 32) { - return yield* new WorkOSAuthConfigurationError({ + return yield* new WorkOSConfigurationError({ message: INVALID_COOKIE_PASSWORD_MESSAGE, }); } @@ -403,16 +403,27 @@ const make = Effect.gen(function* () { }; }); -export type WorkOSAuthService = Effect.Success; +export type WorkOSClientService = Effect.Success; -export class WorkOSAuth extends Context.Service()( - "@executor-js/cloud/WorkOSAuth", +export class WorkOSClient extends Context.Service()( + "@executor-js/cloud/WorkOSClient", ) { static Default = Layer.effect(this)(make).pipe( - Layer.withSpan("WorkOSAuth", { attributes: { module: "WorkOSAuth" } }), + Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }), ); } +// The boot-scoped WorkOS client root — the one neutral service the stateless +// HTTP path AND the MCP session Durable Object both build on (each merges it +// with its own DB + telemetry layers). Named here, beside the client it aliases, +// so a focused backend consumer (the DO, the miniflare test worker) imports just +// this root rather than the whole `api/layers.ts` HTTP assembly. It names NO +// billing service, so the DO — which never bills — does not transitively require +// one. (This used to live in a standalone `api/core-shared-services.ts` purely to +// keep `@tanstack/react-start` out of the DO bundle; that coupling is gone now +// that `handlers.ts` no longer imports react-start, so the alias moved home.) +export const CoreSharedServices = WorkOSClient.Default; + const parseCookie = (cookieHeader: string | null, name: string): string | null => { if (!cookieHeader) return null; const match = cookieHeader diff --git a/apps/cloud/src/services/db.schema.test.ts b/apps/cloud/src/db/db.schema.test.ts similarity index 97% rename from apps/cloud/src/services/db.schema.test.ts rename to apps/cloud/src/db/db.schema.test.ts index 0ba950628..1bcf14c6d 100644 --- a/apps/cloud/src/services/db.schema.test.ts +++ b/apps/cloud/src/db/db.schema.test.ts @@ -20,7 +20,6 @@ import postgres from "postgres"; import { collectTables } from "@executor-js/sdk"; -import executorConfig from "../../executor.config"; import * as cloudSchema from "./schema"; import * as executorSchema from "./executor-schema"; import { combinedSchema } from "./db"; @@ -102,7 +101,7 @@ describe("combinedSchema", () => { const db = drizzle(sql, { schema: combinedSchema }); const fuma = createDrizzleFumaDb({ db, - tables: collectTables(executorConfig.plugins({})), + tables: collectTables(), namespace: "executor_cloud", provider: "postgresql", }); diff --git a/apps/cloud/src/services/db.test.ts b/apps/cloud/src/db/db.test.ts similarity index 93% rename from apps/cloud/src/services/db.test.ts rename to apps/cloud/src/db/db.test.ts index e32c8d2e9..b437b4bf7 100644 --- a/apps/cloud/src/services/db.test.ts +++ b/apps/cloud/src/db/db.test.ts @@ -24,7 +24,7 @@ import { describe, it, expect } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { DbService } from "./db"; -import { makeUserStore } from "./user-store"; +import { makeUserStore } from "../auth/user-store"; const program = (body: Effect.Effect) => Effect.runPromise( @@ -86,14 +86,14 @@ describe("DbService", () => { it("supports nested scopes within a single outer scope (regression: /api/scope pattern)", async () => { // Mirrors api.ts: an outer scope resolves the org, then an inner scope // (the HttpApi request handler) re-acquires DbService and queries again. - const orgId = `org_${crypto.randomUUID()}`; + const organizationId = `org_${crypto.randomUUID()}`; const outer = Layer.provide( Layer.effectDiscard( Effect.gen(function* () { const { db } = yield* DbService; yield* Effect.promise(() => - makeUserStore(db).upsertOrganization({ id: orgId, name: "Acme" }), + makeUserStore(db).upsertOrganization({ id: organizationId, name: "Acme" }), ); }), ), @@ -108,13 +108,13 @@ describe("DbService", () => { return yield* Effect.scoped( Effect.gen(function* () { const { db } = yield* DbService; - return yield* Effect.promise(() => makeUserStore(db).getOrganization(orgId)); + return yield* Effect.promise(() => makeUserStore(db).getOrganization(organizationId)); }).pipe(Effect.provide(DbService.Live)), ) as Effect.Effect<{ id: string; name: string } | null, never, never>; }) as Effect.Effect<{ id: string; name: string } | null, never, never>, ); - expect(result?.id).toBe(orgId); + expect(result?.id).toBe(organizationId); expect(result?.name).toBe("Acme"); }, 15_000); }); diff --git a/apps/cloud/src/services/db.ts b/apps/cloud/src/db/db.ts similarity index 97% rename from apps/cloud/src/services/db.ts rename to apps/cloud/src/db/db.ts index 75026bd5c..5610e4b2d 100644 --- a/apps/cloud/src/services/db.ts +++ b/apps/cloud/src/db/db.ts @@ -25,7 +25,7 @@ import * as executorSchema from "./executor-schema"; // Exported so every drizzle() call in the cloud app shares one schema // object. Historically `mcp-session.ts` built its own and forgot to spread // `executorSchema`, producing runtime "unknown model source" errors that -// only surfaced in prod. See apps/cloud/src/services/db.schema.test.ts. +// only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts. export const combinedSchema = { ...cloudSchema, ...executorSchema }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts new file mode 100644 index 000000000..b0662caeb --- /dev/null +++ b/apps/cloud/src/db/executor-schema.ts @@ -0,0 +1,214 @@ +import { + pgTable, + text, + boolean, + timestamp, + varchar, + uniqueIndex, + json, + bigint, +} from "drizzle-orm/pg-core"; +import { createId } from "fumadb/cuid"; + +export const source = pgTable( + "source", + { + plugin_id: text("plugin_id").notNull(), + kind: text("kind").notNull(), + name: text("name").notNull(), + url: text("url"), + can_remove: boolean("can_remove").notNull().default(true), + can_refresh: boolean("can_refresh").notNull().default(false), + can_edit: boolean("can_edit").notNull().default(false), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("source_scope_id_id_uidx").on(table.scope_id, table.id)], +); + +export const tool = pgTable( + "tool", + { + source_id: text("source_id").notNull(), + plugin_id: text("plugin_id").notNull(), + name: text("name").notNull(), + description: text("description").notNull(), + input_schema: json("input_schema"), + output_schema: json("output_schema"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("tool_scope_id_id_uidx").on(table.scope_id, table.id)], +); + +export const definition = pgTable( + "definition", + { + source_id: text("source_id").notNull(), + plugin_id: text("plugin_id").notNull(), + name: text("name").notNull(), + schema: json("schema").notNull(), + created_at: timestamp("created_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("definition_scope_id_id_uidx").on(table.scope_id, table.id)], +); + +export const secret = pgTable( + "secret", + { + name: text("name").notNull(), + provider: text("provider").notNull(), + owned_by_connection_id: text("owned_by_connection_id"), + created_at: timestamp("created_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("secret_scope_id_id_uidx").on(table.scope_id, table.id)], +); + +export const connection = pgTable( + "connection", + { + provider: text("provider").notNull(), + identity_label: text("identity_label"), + access_token_secret_id: text("access_token_secret_id").notNull(), + refresh_token_secret_id: text("refresh_token_secret_id"), + expires_at: bigint("expires_at", { mode: "bigint" }), + scope: text("scope"), + provider_state: json("provider_state"), + identity_override: json("identity_override"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("connection_scope_id_id_uidx").on(table.scope_id, table.id)], +); + +export const oauth2_session = pgTable( + "oauth2_session", + { + plugin_id: text("plugin_id").notNull(), + strategy: text("strategy").notNull(), + connection_id: text("connection_id").notNull(), + token_scope: text("token_scope").notNull(), + redirect_url: text("redirect_url").notNull(), + payload: json("payload").notNull(), + expires_at: bigint("expires_at", { mode: "bigint" }).notNull(), + created_at: timestamp("created_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("oauth2_session_scope_id_id_uidx").on(table.scope_id, table.id)], +); + +export const credential_binding = pgTable( + "credential_binding", + { + plugin_id: text("plugin_id").notNull(), + source_id: text("source_id").notNull(), + source_scope_id: text("source_scope_id").notNull(), + slot_key: text("slot_key").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + secret_id: text("secret_id"), + secret_scope_id: text("secret_scope_id"), + connection_id: text("connection_id"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("credential_binding_scope_id_id_uidx").on(table.scope_id, table.id)], +); + +export const plugin_storage = pgTable( + "plugin_storage", + { + plugin_id: text("plugin_id").notNull(), + collection: text("collection").notNull(), + key: text("key").notNull(), + data: json("data").notNull(), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("plugin_storage_scope_id_id_uidx").on(table.scope_id, table.id)], +); + +export const tool_policy = pgTable( + "tool_policy", + { + pattern: text("pattern").notNull(), + action: text("action").notNull(), + position: text("position").notNull(), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("tool_policy_scope_id_id_uidx").on(table.scope_id, table.id)], +); + +export const blob = pgTable( + "blob", + { + namespace: text("namespace").notNull(), + key: text("key").notNull(), + value: text("value").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("blob_id_uidx").on(table.id)], +); + +export const private_executor_cloud_settings = pgTable("private_executor_cloud_settings", { + id: varchar("id", { length: 255 }).primaryKey().notNull(), + version: varchar("version", { length: 255 }).notNull().default("1.0.0"), +}); diff --git a/apps/cloud/src/db/fuma.ts b/apps/cloud/src/db/fuma.ts new file mode 100644 index 000000000..e780a2b63 --- /dev/null +++ b/apps/cloud/src/db/fuma.ts @@ -0,0 +1,71 @@ +import { Effect, Layer } from "effect"; +import { type FumaDB } from "fumadb"; +import { type DrizzleConfig } from "fumadb/adapters/drizzle"; +import { type schema as fumaSchema, type RelationsMap } from "fumadb/schema"; + +import { + createExecutorFumaDb, + DbProvider, + type ExecutorDbHandle, + type ExecutorDbProvider, +} from "@executor-js/api/server"; +import type { FumaDb, FumaTables } from "@executor-js/sdk"; + +import { DbService } from "./db"; + +type DrizzleFumaSchema = ReturnType< + typeof fumaSchema> +>; + +export interface DrizzleFumaDb { + readonly db: FumaDb>; + readonly fuma: FumaDB[]>; +} + +export interface CreateDrizzleFumaDbOptions { + readonly db: DrizzleConfig["db"]; + readonly tables: TTables; + readonly namespace: string; + readonly version?: string; + readonly provider: ExecutorDbProvider; +} + +// Cloud opens its own postgres-js drizzle handle (see ./db.ts) and runs +// migrations out-of-band, so this is the pure FumaDB assembly over an +// already-opened `db`. Delegates to the shared factory; the local wrapper +// keeps the cloud-specific default version + option names. +export const createDrizzleFumaDb = ( + options: CreateDrizzleFumaDbOptions, +): DrizzleFumaDb => + createExecutorFumaDb(options.db, { + tables: options.tables, + namespace: options.namespace, + version: options.version ?? "1.0.0", + provider: options.provider, + }); + +export const CLOUD_NAMESPACE = "executor_cloud"; + +// Shared DbProvider seam (P2a). Cloud opens a fresh postgres-js connection per +// request (Cloudflare forbids sharing I/O across handlers); this assembles the +// FumaDB handle over the request-scoped `DbService.db`. Migrations run +// out-of-band, so there is no schema bring-up here, and `close` is a no-op — +// `DbService.Live` owns the postgres connection lifecycle. +export const cloudDbProviderLayer = ( + tables: FumaTables, +): Layer.Layer => + Layer.effect(DbProvider)( + Effect.map(DbService.asEffect(), ({ db }): ExecutorDbHandle => { + const fuma = createDrizzleFumaDb({ + db, + tables, + namespace: CLOUD_NAMESPACE, + provider: "postgresql", + }); + return { + db: fuma.db, + fuma: fuma.fuma, + close: async () => {}, + }; + }), + ); diff --git a/apps/cloud/src/services/fumadb-cutover-migration.node.test.ts b/apps/cloud/src/db/fumadb-cutover-migration.node.test.ts similarity index 100% rename from apps/cloud/src/services/fumadb-cutover-migration.node.test.ts rename to apps/cloud/src/db/fumadb-cutover-migration.node.test.ts diff --git a/apps/cloud/src/services/schema.ts b/apps/cloud/src/db/schema.ts similarity index 100% rename from apps/cloud/src/services/schema.ts rename to apps/cloud/src/db/schema.ts diff --git a/apps/cloud/src/edge/index.ts b/apps/cloud/src/edge/index.ts new file mode 100644 index 000000000..831992882 --- /dev/null +++ b/apps/cloud/src/edge/index.ts @@ -0,0 +1,10 @@ +// --------------------------------------------------------------------------- +// Edge concerns — the analytics/marketing request middlewares that run at the +// worker edge BEFORE the app's own mcp + api dispatch. None of these touch the +// Effect app layer; they proxy or tunnel to external services (the marketing +// worker, Sentry, PostHog). +// --------------------------------------------------------------------------- + +export { marketingMiddleware } from "./marketing"; +export { sentryTunnelMiddleware } from "./sentry-tunnel"; +export { posthogProxyMiddleware } from "./posthog"; diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts new file mode 100644 index 000000000..8d039c982 --- /dev/null +++ b/apps/cloud/src/edge/marketing.ts @@ -0,0 +1,63 @@ +// --------------------------------------------------------------------------- +// Marketing routes — proxied to the marketing worker via service binding. +// +// On the production domain (`executor.sh`), marketing paths and the +// unauthenticated landing page are served by the separate `executor-marketing` +// worker (bound as `env.MARKETING`). In local dev that worker isn't running, so +// unauthenticated visits fall through to the cloud app's routes (the sign-in +// page). +// --------------------------------------------------------------------------- + +import { env } from "cloudflare:workers"; +import { createMiddleware } from "@tanstack/react-start"; + +const MARKETING_PATHS = [ + "/home", + "/setup", + "/privacy", + "/terms", + "/api/detect", + "/_astro", + "/og-image.png", + "/pattern-graph-paper.svg", +]; + +const isMarketingPath = (pathname: string) => + MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); + +const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined; + +const parseCookie = (cookieHeader: string | null, name: string): string | null => { + if (!cookieHeader) return null; + const match = cookieHeader + .split(";") + .map((v) => v.trim()) + .find((v) => v.startsWith(`${name}=`)); + return match ? match.slice(name.length + 1) || null : null; +}; + +export const marketingMiddleware = createMiddleware({ type: "request" }).server( + async ({ pathname, request, next }) => { + // Only proxy to the marketing worker on the production domain. In local + // dev we don't run `executor-marketing`, so unauthenticated visits fall + // through to the cloud app's routes (which show the sign-in page). + const host = new URL(request.url).hostname; + if (host !== "executor.sh") return next(); + + const shouldProxyToMarketing = + isMarketingPath(pathname) || + (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session")); + + if (!shouldProxyToMarketing) return next(); + + const marketing = getMarketingWorker(); + if (!marketing) return next(); + + const url = new URL(request.url); + // Rewrite /home to / so marketing worker serves its homepage + if (pathname === "/home") { + url.pathname = "/"; + } + return marketing.fetch(new Request(url, request)); + }, +); diff --git a/apps/cloud/src/edge/posthog.ts b/apps/cloud/src/edge/posthog.ts new file mode 100644 index 000000000..6badd574f --- /dev/null +++ b/apps/cloud/src/edge/posthog.ts @@ -0,0 +1,35 @@ +// --------------------------------------------------------------------------- +// PostHog reverse proxy — the browser SDK targets a build-randomized +// first-party path and we forward to PostHog's ingest + asset hosts. Keeps +// events flowing past adblockers that match *.posthog.com. See +// https://posthog.com/docs/advanced/proxy/cloudflare +// --------------------------------------------------------------------------- + +import { createMiddleware } from "@tanstack/react-start"; + +const POSTHOG_INGEST_HOST = "us.i.posthog.com"; +const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com"; +const POSTHOG_PROXY_PATH = `/api/${(import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a").replace( + /^\/+|\/+$/g, + "", +)}`; + +export const posthogProxyMiddleware = createMiddleware({ type: "request" }).server( + ({ pathname, request, next }) => { + if (pathname !== POSTHOG_PROXY_PATH && !pathname.startsWith(`${POSTHOG_PROXY_PATH}/`)) { + return next(); + } + + const url = new URL(request.url); + url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`) + ? POSTHOG_ASSETS_HOST + : POSTHOG_INGEST_HOST; + url.protocol = "https:"; + url.port = ""; + url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/"; + + const upstream = new Request(url, request); + upstream.headers.delete("cookie"); + return fetch(upstream); + }, +); diff --git a/apps/cloud/src/sentry-tunnel.ts b/apps/cloud/src/edge/sentry-tunnel.ts similarity index 64% rename from apps/cloud/src/sentry-tunnel.ts rename to apps/cloud/src/edge/sentry-tunnel.ts index f63877444..acb656fa4 100644 --- a/apps/cloud/src/sentry-tunnel.ts +++ b/apps/cloud/src/edge/sentry-tunnel.ts @@ -1,3 +1,13 @@ +// --------------------------------------------------------------------------- +// Sentry tunnel — the browser SDK POSTs envelopes to /api/sentry-tunnel +// (configured in routes/__root.tsx) to dodge adblockers and CSP. We parse the +// envelope header to recover the DSN, validate against our own, and forward the +// body to Sentry's ingest endpoint. See +// https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option +// --------------------------------------------------------------------------- + +import { env } from "cloudflare:workers"; +import { createMiddleware } from "@tanstack/react-start"; import { Data, Effect, Schema } from "effect"; class SentryTunnelError extends Data.TaggedError("SentryTunnelError")<{ @@ -51,3 +61,16 @@ export const handleSentryTunnelRequest = (request: Request, configuredDsn: strin catch: (cause) => new SentryTunnelError({ cause }), }); }).pipe(Effect.catch(() => Effect.succeed(badSentryEnvelopeResponse()))); + +export const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server( + ({ pathname, request, next }) => { + if (pathname !== "/api/sentry-tunnel" || request.method !== "POST") { + return next(); + } + + const configuredDsn = (env as { SENTRY_DSN?: string }).SENTRY_DSN; + if (!configuredDsn) return new Response(null, { status: 204 }); + + return Effect.runPromise(handleSentryTunnelRequest(request, configuredDsn)); + }, +); diff --git a/apps/cloud/src/engine/execution-stack-metered.ts b/apps/cloud/src/engine/execution-stack-metered.ts new file mode 100644 index 000000000..228ef70cd --- /dev/null +++ b/apps/cloud/src/engine/execution-stack-metered.ts @@ -0,0 +1,54 @@ +// --------------------------------------------------------------------------- +// Metered execution stack — the HTTP executor plane's billing overlay. +// +// Cloud is the only host that meters executions, and only the HTTP `/api/*` +// executor plane does so (the MCP session DO never bills). This module is where +// the billing decorator binds to the neutral `CloudExecutionStackLayer`: it +// overrides the base stack's no-op `EngineDecorator` with one that calls +// `AutumnService.trackExecution` after each execution. +// +// Keeping this in the cloud APP layer (not the neutral `engine/execution-stack.ts`) +// is the billing-boundary line: the neutral stack the DO shares names no billing +// service; the metered overlay — provided ONLY here — does. +// --------------------------------------------------------------------------- + +import { Effect, Layer } from "effect"; + +import { + CodeExecutorProvider, + DbProvider, + EngineDecorator, + HostConfig, + PluginsProvider, + type EngineStackIdentity, +} from "@executor-js/api/server"; + +import { AutumnService } from "../extensions/billing/service"; +import type { DbService } from "../db/db"; +import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; +import { withExecutionUsageTracking } from "./execution-usage"; + +// Usage-metering decorator bound to the billing service. `trackExecution` is +// fire-and-forget (`Effect.runFork`) so the billing call can't stall a +// user-facing execution. +export const CloudMeteringEngineDecorator: Layer.Layer = + Layer.effect(EngineDecorator)( + Effect.map(AutumnService.asEffect(), (autumn): EngineDecorator["Service"] => ({ + decorate: (engine, identity: EngineStackIdentity) => + withExecutionUsageTracking(identity.organizationId, engine, (organizationId) => + Effect.runFork(autumn.trackExecution(organizationId)), + ), + })), + ); + +/** + * The execution-stack seams for the metered HTTP executor plane: the four + * billing-free `CloudExecutionSeamsLayer` seams plus the billing decorator. + * Requires `DbService` (per-request Hyperdrive db) and `AutumnService` (usage + * metering) from the surrounding context. + */ +export const CloudMeteredExecutionStackLayer: Layer.Layer< + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator, + never, + AutumnService | DbService +> = Layer.merge(CloudExecutionSeamsLayer, CloudMeteringEngineDecorator); diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts new file mode 100644 index 000000000..beb5ead85 --- /dev/null +++ b/apps/cloud/src/engine/execution-stack.ts @@ -0,0 +1,117 @@ +// --------------------------------------------------------------------------- +// Cloud execution-stack seams. +// +// The shared `makeExecutionStack` (@executor-js/api/server) owns the body: +// makeScopedExecutor -> createExecutionEngine -> EngineDecorator.decorate. +// Used by the protected HTTP API (per-request) and the MCP session DO +// (per-session) so changes to the stack flow to both. Cloud supplies the five +// seam Layers it reads from; the only cloud-specific differences are the +// Cloudflare dynamic-worker code substrate and the usage-metering decorator. +// +// - DbProvider -> cloudDbProviderLayer: rebuilds the postgres-js fuma +// client per request off the request-scoped +// `DbService.db` (Hyperdrive forbids sharing an I/O +// handle across requests). The shared factory reads +// `db` without caching, preserving per-request rebuild. +// - PluginsProvider -> fresh per-request plugins with the Worker env's +// WorkOS credentials. +// - HostConfig -> `allowLocalNetwork` is config-driven (the +// `ALLOW_LOCAL_NETWORK` var; production leaves it unset +// -> `false`, the test workers set it `"true"`). It is +// an SSRF/private-network guard, so it MUST NOT key off +// a test flag. `webBaseUrl` is `VITE_PUBLIC_SITE_URL ?? +// executor.sh`. +// - CodeExecutorProvider -> `makeDynamicWorkerExecutor({ loader: env.LOADER })`. +// - EngineDecorator -> the BASE stack uses the no-op decorator (the MCP +// session DO never meters); the METERED stack (HTTP +// executor plane only) overrides it with the billing +// decorator (`CloudMeteredExecutionStackLayer`, +// ../engine/execution-stack-metered.ts). Billing lives in +// the cloud app, not this neutral stack. +// --------------------------------------------------------------------------- + +import { env } from "cloudflare:workers"; +import { Layer } from "effect"; + +import { + CodeExecutorProvider, + DbProvider, + EngineDecorator, + EngineDecoratorNoop, + HostConfig, + PluginsProvider, + collectTables, +} from "@executor-js/api/server"; +import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; + +import executorConfig from "../../executor.config"; +import { DbService } from "../db/db"; +import { cloudDbProviderLayer } from "../db/fuma"; + +export { makeExecutionStack } from "@executor-js/api/server"; + +// The executor table set is fixed (plugin-independent), so the per-request +// DbProvider rebuilds the fuma client over the same schema. +export const CloudDbProvider = cloudDbProviderLayer(collectTables()); + +// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault +// credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`. +export const CloudPluginsProvider: Layer.Layer = Layer.succeed(PluginsProvider)({ + plugins: () => + executorConfig.plugins({ + workosCredentials: { + apiKey: env.WORKOS_API_KEY, + clientId: env.WORKOS_CLIENT_ID, + }, + }), +}); + +export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, () => ({ + // SSRF / private-network egress guard. Config-driven, NOT a test flag: + // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); + // the test workers (`wrangler.test.jsonc` / `wrangler.miniflare.jsonc`) opt in + // with `"true"` so fixtures can reach localhost. See `hosted-http-client.ts`. + allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", + webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", +})); + +export const CloudCodeExecutorProvider: Layer.Layer = Layer.sync( + CodeExecutorProvider, + () => makeDynamicWorkerExecutor({ loader: env.LOADER }), +); + +/** + * The four billing-free execution-stack seams (db / plugins / host-config / + * code-executor) — everything `makeExecutionStack` reads EXCEPT the + * `EngineDecorator`. The metered HTTP plane composes this with the billing + * decorator (../engine/execution-stack-metered.ts); the neutral stack below adds + * the no-op decorator. Exported so the metered overlay builds over the SAME four + * seams rather than relying on a layer override. + */ +export const CloudExecutionSeamsLayer: Layer.Layer< + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider, + never, + DbService +> = Layer.mergeAll( + CloudDbProvider, + CloudPluginsProvider, + CloudHostConfig, + CloudCodeExecutorProvider, +); + +/** + * The five execution-stack seams the shared `makeExecutionStack` reads from, + * with the NO-OP engine decorator. This is the neutral stack: it requires only + * `DbService` (per-request Hyperdrive db) and carries NO billing dependency, so + * the MCP session DO — which never meters — can build an engine without dragging + * in any billing service. + * + * The HTTP executor plane (the only path that meters) uses + * `CloudMeteredExecutionStackLayer` (../engine/execution-stack-metered.ts), which + * swaps the no-op decorator for the billing one. + */ +export const CloudExecutionStackLayer: Layer.Layer< + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator, + never, + DbService +> = Layer.merge(CloudExecutionSeamsLayer, EngineDecoratorNoop); diff --git a/apps/cloud/src/api/execution-usage.ts b/apps/cloud/src/engine/execution-usage.ts similarity index 100% rename from apps/cloud/src/api/execution-usage.ts rename to apps/cloud/src/engine/execution-usage.ts diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index e1c66a269..98cc0b3c7 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -20,6 +20,10 @@ declare global { DATABASE_URL?: string; EXECUTOR_DIRECT_DATABASE_URL?: string; + // SSRF / private-network egress guard. Unset in production -> the guard is + // ON; the test workers set "true" so fixtures can reach localhost. + ALLOW_LOCAL_NETWORK?: string; + // Billing AUTUMN_SECRET_KEY?: string; diff --git a/apps/cloud/src/extensions-reachability.test.ts b/apps/cloud/src/extensions-reachability.test.ts new file mode 100644 index 000000000..43ab6d47f --- /dev/null +++ b/apps/cloud/src/extensions-reachability.test.ts @@ -0,0 +1,73 @@ +// --------------------------------------------------------------------------- +// Realistic reachability smoke test for the composed cloud handler. +// +// Boots the ACTUAL `cloudApiHandler` — `ExecutorApp.make`'s `toWebHandler`, the +// exact handler `start.ts` forwards app-owned requests to — and drives it with +// raw `Request`s to prove every served surface is REACHED, not dropped into a +// 404 / the SPA fallback. This is the integration complement to +// `app-paths.test.ts` (which guards the `start.ts` dispatch decision): together +// they cover both halves of the billing-404 class — +// - app-paths.test.ts: "does start.ts forward the /api surface to the handler?" +// - this file: "does the handler actually serve billing + docs?" +// +// It catches a route being dropped from `makeCloudExtensionRoutes`, the Autumn +// proxy / Swagger being unmounted, the `/api` prefix wiring regressing, etc. +// +// Runs in the workers pool (real workerd) because the composed app transitively +// imports `agents/mcp` (the MCP envelope), which is workerd-only. The asserted +// surfaces short-circuit before any real network I/O: the billing proxy 401s +// before calling Autumn, the protected API 401/403s at the auth gate, and the +// spec / Swagger / discovery docs are static. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; + +import { cloudApiHandler } from "./app"; + +const handler = cloudApiHandler().handler; + +const call = (method: string, path: string, init: RequestInit = {}) => + handler(new Request(`http://test.local${path}`, { method, ...init })); + +describe("cloud composed-handler reachability", () => { + it("serves the Autumn billing proxy (401 JSON, NOT a 404 SPA fallback)", async () => { + const res = await call("POST", "/api/billing/customer", { + headers: { "content-type": "application/json" }, + body: "{}", + }); + // The regression returned the TanStack SPA fallback (200 text/html). The real + // handler reaches the billing route and rejects the unauthenticated call. + expect(res.status).toBe(401); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(await res.json()).toEqual({ error: "Unauthorized", code: "unauthorized" }); + }); + + it("serves Swagger UI at /api/docs", async () => { + const res = await call("GET", "/api/docs"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect((await res.text()).toLowerCase()).toContain("swagger"); + }); + + it("serves the OpenAPI spec at /api/openapi.json", async () => { + const res = await call("GET", "/api/openapi.json"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + const spec = (await res.json()) as { paths?: Record }; + expect(spec.paths).toBeDefined(); + // The spec is prefixed with /api, so a real route like scope is present. + expect(Object.keys(spec.paths ?? {}).some((p) => p.includes("/scope"))).toBe(true); + }); + + it("reaches the protected API auth gate at /api/scope (error JSON, NOT SPA HTML)", async () => { + const res = await call("GET", "/api/scope"); + expect([401, 403]).toContain(res.status); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(await res.json()).toHaveProperty("code"); + }); + + // (The MCP envelope + its /.well-known/* discovery docs are exercised by the + // mcp-flow / mcp-miniflare suites; the dispatch half is pinned in + // app-paths.test.ts. They proxy to WorkOS, unreachable from this isolate, so + // they are not re-asserted here.) +}); diff --git a/apps/cloud/src/extensions/billing/plans.ts b/apps/cloud/src/extensions/billing/plans.ts new file mode 100644 index 000000000..2de6aa3ab --- /dev/null +++ b/apps/cloud/src/extensions/billing/plans.ts @@ -0,0 +1,79 @@ +import { enterprise, team } from "../../../autumn.config"; + +export const PAID_AUTUMN_PLAN_IDS = new Set([team.id, enterprise.id]); + +export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]); + +// --------------------------------------------------------------------------- +// Free-tier organization-creation limit — the createOrganization gate. +// +// These predicates read the Autumn plan config above, so they live with the +// billing config (NOT in `auth/organization.ts`, which the billing-free MCP +// session DO bundle reaches). Used only by `auth/handlers.ts`'s +// `createOrganization` handler. +// --------------------------------------------------------------------------- + +export const FREE_ORGANIZATIONS_PER_USER_LIMIT = 3; + +export type OrganizationLimitSubscriptionSummary = { + readonly planId?: string | null; + readonly status?: string | null; +}; + +export type OrganizationLimitMembershipSummary = { + readonly organizationId: string; + readonly status?: string | null; +}; + +export const isPaidOrganizationSubscription = ( + subscription: OrganizationLimitSubscriptionSummary, +): boolean => + subscription.planId != null && + PAID_AUTUMN_PLAN_IDS.has(subscription.planId) && + ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""); + +export const hasPaidOrganizationSubscription = ( + subscriptions: ReadonlyArray, +): boolean => subscriptions.some(isPaidOrganizationSubscription); + +export const shouldApplyFreeOrganizationLimit = ( + activeMemberships: ReadonlyArray, + paidOrganizationIds: ReadonlySet, +): boolean => + !activeMemberships.some((membership) => paidOrganizationIds.has(membership.organizationId)); + +export const isOverFreeOrganizationLimit = ( + activeMemberships: ReadonlyArray, +): boolean => activeMemberships.length >= FREE_ORGANIZATIONS_PER_USER_LIMIT; + +// --------------------------------------------------------------------------- +// Per-plan member seat limits — the org member seat-gate (reserveMemberSlot). +// Reads the same Autumn plan config. Used by the account provider seat-gate. +// --------------------------------------------------------------------------- + +const MEMBER_LIMITS: Record = { + free: 3, + "free-pay-as-you-go": 3, + team: null, + enterprise: null, +}; + +export const DEFAULT_MEMBER_LIMIT = 3; + +export type AutumnSubscriptionSummary = { + readonly planId?: string | null; + readonly status?: string | null; +}; + +export const selectActiveMemberLimitPlan = ( + subscriptions: ReadonlyArray, +): string => { + const active = + subscriptions.find((subscription) => + ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""), + ) ?? subscriptions[0]; + return active?.planId ?? "free"; +}; + +export const getMemberLimitForPlan = (planId: string): number | null => + planId in MEMBER_LIMITS ? MEMBER_LIMITS[planId] : DEFAULT_MEMBER_LIMIT; diff --git a/apps/cloud/src/api/autumn.ts b/apps/cloud/src/extensions/billing/route.ts similarity index 90% rename from apps/cloud/src/api/autumn.ts rename to apps/cloud/src/extensions/billing/route.ts index 25bca4ecc..bf8809df1 100644 --- a/apps/cloud/src/api/autumn.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -3,8 +3,8 @@ import { Cause, Effect } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { autumnHandler } from "autumn-js/backend"; -import { WorkOSAuth } from "../auth/workos"; -import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; +import { WorkOSClient } from "../../auth/workos"; +import { HttpResponseError, isServerError, toErrorServerResponse } from "../../api/error-response"; const handler = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; @@ -18,7 +18,7 @@ const handler = Effect.gen(function* () { }), ); - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const session = yield* workos.authenticateRequest(webRequest); if (!session || !session.organizationId) { @@ -58,7 +58,7 @@ const handler = Effect.gen(function* () { clientOptions: { secretKey: env.AUTUMN_SECRET_KEY ?? "", }, - pathPrefix: "/autumn", + pathPrefix: "/api/billing", }), ); @@ -81,4 +81,4 @@ const handler = Effect.gen(function* () { }), ); -export const AutumnRoutesLive = HttpRouter.add("*", "/autumn/*", handler); +export const AutumnRoutesLive = HttpRouter.add("*", "/api/billing/*", handler); diff --git a/apps/cloud/src/services/autumn.test-layer.ts b/apps/cloud/src/extensions/billing/service.test-layer.ts similarity index 94% rename from apps/cloud/src/services/autumn.test-layer.ts rename to apps/cloud/src/extensions/billing/service.test-layer.ts index b1c482d9e..4b1ca1dc5 100644 --- a/apps/cloud/src/services/autumn.test-layer.ts +++ b/apps/cloud/src/extensions/billing/service.test-layer.ts @@ -1,7 +1,7 @@ import { Effect, Layer } from "effect"; import type { Autumn } from "autumn-js"; -import { AutumnService, type IAutumnService } from "./autumn"; +import { AutumnService, type IAutumnService } from "./service"; export type AutumnTestSubscriptionSummary = { readonly planId?: string | null; diff --git a/apps/cloud/src/services/autumn.ts b/apps/cloud/src/extensions/billing/service.ts similarity index 100% rename from apps/cloud/src/services/autumn.ts rename to apps/cloud/src/extensions/billing/service.ts diff --git a/apps/cloud/src/api/docs.ts b/apps/cloud/src/extensions/docs.ts similarity index 82% rename from apps/cloud/src/api/docs.ts rename to apps/cloud/src/extensions/docs.ts index ba3729c81..6106d4933 100644 --- a/apps/cloud/src/api/docs.ts +++ b/apps/cloud/src/extensions/docs.ts @@ -5,7 +5,7 @@ import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; import { OrgApi } from "../org/api"; -import { ProtectedCloudApi } from "./protected-layers"; +import { ProtectedCloudApi } from "../api/layers"; export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi).add(CloudAuthApi).add(OrgApi); @@ -13,11 +13,11 @@ const spec = OpenApi.fromApi(CloudOpenApi); export const CloudOpenApiJsonLive = HttpRouter.add( "GET", - "/openapi.json", + "/api/openapi.json", Effect.succeed(HttpServerResponse.jsonUnsafe(spec)), ); export const CloudDocsLive = Layer.mergeAll( - HttpApiSwagger.layer(CloudOpenApi, { path: "/docs" }), + HttpApiSwagger.layer(CloudOpenApi, { path: "/api/docs" }), CloudOpenApiJsonLive, ); diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts new file mode 100644 index 000000000..b60f66c9c --- /dev/null +++ b/apps/cloud/src/extensions/routes.ts @@ -0,0 +1,99 @@ +// --------------------------------------------------------------------------- +// Cloud's app-only HTTP surface — the `extensions.routes` fed to +// `ExecutorApp.make`. None of these are seams the shared core names; they are +// cloud-specific routes mounted alongside the executor `/api/*` plane: +// +// - the WorkOS session routes (login / callback / me / organizations / +// switch-organization / invitations / MCP-approval) — `NonProtectedApi`. +// - the cloud-only WorkOS domain-verification routes — `OrgHttpApi`. +// - Swagger UI + the OpenAPI JSON for the full cloud spec. +// - the Autumn billing proxy (`/api/billing/*`) — billing-as-extension (the +// `extensions.routes` SEAM, but served under `/api` like everything else). +// - the global request-failure logging middleware. +// +// They all serve UNDER the `/api` prefix (the same namespace the protected + +// account APIs use), so each HttpApi group is provided the shared +// `apiPrefixedRouter` view; the plain `HttpRouter.add` routes use literal +// `/api/...` paths. The per-request `DbService` / `UserStoreService` the session +// handlers read is supplied by `RequestScopedServicesLive` (rebuilt per request +// so the postgres.js socket lives in the request fiber's scope). +// --------------------------------------------------------------------------- + +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 { requestScopedMiddleware } from "@executor-js/api/server"; + +import { UserStoreService } from "../auth/context"; +import { + CloudAuthPublicHandlers, + CloudSessionAuthHandlers, + NonProtectedApi, +} from "../auth/handlers"; +import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; +import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; +import { OrgApi, OrgHttpApi } from "../org/api"; +import { OrgHandlers } from "../org/handlers"; +import { AutumnService } from "../extensions/billing/service"; +import { DbService } from "../db/db"; +import { ProtectedCloudApi } from "../api/layers"; +import { AutumnRoutesLive } from "./billing/route"; +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) + .add(OrgApi) + .prefix("/api"); + +const spec = OpenApi.fromApi(CloudOpenApi); + +/** + * Build cloud's app-only extension routes. `rsLive` is the per-request DB layer + * the session handlers read; passed in so tests can swap a counting fake. + * + * `AutumnService.Default` is provided to the session + org groups because the + * `createOrganization` free-limit gate and the domain-verification-link gate + * read it — the few app-only billing touchpoints. It is NOT on the neutral boot + * core. + */ +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; `OrgAuth` enforces an + // authenticated org session. No per-request DB scoping needed. + const OrgRoutes = HttpApiBuilder.layer(OrgHttpApi).pipe( + Layer.provide(OrgHandlers), + Layer.provideMerge(OrgAuthLive), + Layer.provideMerge(AutumnService.Default), + Layer.provide(apiPrefixedRouter), + ); + + // Swagger UI at /api/docs + the OpenAPI JSON at /api/openapi.json, over the + // `/api`-prefixed spec (so the served paths match). + const DocsRoutes = Layer.mergeAll( + HttpApiSwagger.layer(CloudOpenApi, { path: "/api/docs" }), + HttpRouter.add("GET", "/api/openapi.json", Effect.succeed(HttpServerResponse.jsonUnsafe(spec))), + ); + + return [SessionRoutes, OrgRoutes, DocsRoutes, AutumnRoutesLive, ApiErrorLoggingLive] as const; +}; diff --git a/apps/cloud/src/mcp-flow.test.ts b/apps/cloud/src/mcp-flow.test.ts index 651f4fe86..bd83c5fcf 100644 --- a/apps/cloud/src/mcp-flow.test.ts +++ b/apps/cloud/src/mcp-flow.test.ts @@ -14,7 +14,7 @@ // Two auth seams are faked: `McpAuth.verifyBearer` and the live WorkOS // membership check. The real bearer impl calls WorkOS's JWKS endpoint, // which we can't reach from the test isolate. -// Test bearer format is `test-accept::::` +// Test bearer format is `test-accept::::` // (see `makeTestBearer` in test-worker.ts). // // The node-pool test (`mcp-session.e2e.node.test.ts`) covers the DO's @@ -28,7 +28,7 @@ import { env, runDurableObjectAlarm, runInDurableObject, SELF } from "cloudflare import { Effect } from "effect"; import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; -import { makeTestBearer } from "./test-bearer"; +import { makeTestBearer } from "./testing/test-bearer"; // --------------------------------------------------------------------------- // Constants @@ -172,7 +172,35 @@ describe("/mcp CORS preflight", () => { expect(allowedHeaders).toContain("mcp-session-id"); expect(allowedHeaders).toContain("authorization"); expect(allowedHeaders).toContain("content-type"); - expect(response.headers.get("access-control-expose-headers")).toBe("mcp-session-id"); + // Envelope canonical CORS superset: expose-headers now includes + // WWW-Authenticate alongside mcp-session-id. + const exposeHeaders = response.headers.get("access-control-expose-headers") ?? ""; + expect(exposeHeaders).toContain("mcp-session-id"); + }); +}); + +describe("/mcp method handling", () => { + it("returns 405 JSON-RPC -32001 for a method the transport doesn't serve", async () => { + // PUT/PATCH are not GET/POST/DELETE/OPTIONS — the envelope rejects them + // BEFORE dispatch so no session engine spins up (the OLD mcpApp 405). + for (const method of ["PUT", "PATCH"] as const) { + const response = await SELF.fetch(MCP_URL, { + method, + headers: { + authorization: `Bearer ${makeTestBearer(nextAccountId(), nextOrgId())}`, + "content-type": CONTENT_TYPE_JSON, + }, + body: JSON.stringify(TOOLS_LIST_REQUEST), + }); + expect(response.status, `${method} should be 405`).toBe(405); + const body = (await response.json()) as { + jsonrpc: string; + error: { code: number; message: string }; + }; + expect(body.jsonrpc).toBe("2.0"); + expect(body.error.code).toBe(-32001); + expect(body.error.message).toMatch(/method not allowed/i); + } }); }); @@ -194,6 +222,25 @@ describe("/.well-known/oauth-protected-resource", () => { scopes_supported: [], }); }); + + it("answers an OPTIONS CORS preflight on the discovery path with 204 + CORS", async () => { + // OLD mcpApp answered OPTIONS for ALL mcp paths (incl /.well-known/*) before + // the route switch; the envelope now registers an OPTIONS preflight per + // discovery path, not only /mcp. + const response = await SELF.fetch(OAUTH_RESOURCE_URL, { + method: "OPTIONS", + headers: { + origin: "https://claude.ai", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }, + }); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS"); + const allowedHeaders = response.headers.get("access-control-allow-headers") ?? ""; + expect(allowedHeaders).toContain("authorization"); + }); }); // --------------------------------------------------------------------------- @@ -277,7 +324,14 @@ describe("/mcp unauthorized", () => { expect(wwwAuth).toContain( "https://test-resource.example.com/.well-known/oauth-protected-resource/mcp", ); - expect(await response.json()).toEqual({ error: "unauthorized" }); + // Envelope canonicalizes the 401 body to a JSON-RPC error (the legacy + // `{ error: "unauthorized" }` body cannot be overridden through the shared + // envelope, which only lets the provider set the WWW-Authenticate challenge). + expect(await response.json()).toEqual({ + jsonrpc: "2.0", + error: { code: -32001, message: "Unauthorized" }, + id: null, + }); }); }); @@ -373,12 +427,12 @@ describe("/mcp unknown session id", () => { describe("/mcp notification responses", () => { it("returns 202 with an empty body for notifications/initialized", async () => { - const orgId = nextOrgId(); + const organizationId = nextOrgId(); const accountId = nextAccountId(); - await seedOrg(orgId); + await seedOrg(organizationId); const initializeResponse = await mcpPost({ - bearer: makeTestBearer(accountId, orgId), + bearer: makeTestBearer(accountId, organizationId), body: INITIALIZE_REQUEST, }); expect(initializeResponse.status).toBe(200); @@ -386,7 +440,7 @@ describe("/mcp notification responses", () => { expect(sessionId).toBeTruthy(); const notificationResponse = await mcpPost({ - bearer: makeTestBearer(accountId, orgId), + bearer: makeTestBearer(accountId, organizationId), sessionId, body: INITIALIZED_NOTIFICATION, }); @@ -401,12 +455,12 @@ describe("/mcp notification responses", () => { describe("/mcp session restore", () => { it("restores an initialized SDK transport from durable storage", async () => { - const orgId = nextOrgId(); + const organizationId = nextOrgId(); const accountId = nextAccountId(); - await seedOrg(orgId); + await seedOrg(organizationId); const initializeResponse = await mcpPost({ - bearer: makeTestBearer(accountId, orgId), + bearer: makeTestBearer(accountId, organizationId), body: INITIALIZE_REQUEST, }); expect(initializeResponse.status).toBe(200); @@ -420,7 +474,7 @@ describe("/mcp session restore", () => { }); const response = await mcpPost({ - bearer: makeTestBearer(accountId, orgId), + bearer: makeTestBearer(accountId, organizationId), sessionId, body: TOOLS_LIST_REQUEST, }); @@ -434,10 +488,10 @@ describe("/mcp session restore", () => { }, 15_000); it("keeps JSON POST responses after a session is restored by a GET reconnect", async () => { - const orgId = nextOrgId(); + const organizationId = nextOrgId(); const accountId = nextAccountId(); - const bearer = makeTestBearer(accountId, orgId); - await seedOrg(orgId); + const bearer = makeTestBearer(accountId, organizationId); + await seedOrg(organizationId); const initializeResponse = await mcpPost({ bearer, @@ -493,10 +547,10 @@ describe("/mcp session restore", () => { }, 15_000); it("restores an initialized session after the idle alarm suspends the runtime", async () => { - const orgId = nextOrgId(); + const organizationId = nextOrgId(); const accountId = nextAccountId(); - const bearer = makeTestBearer(accountId, orgId); - await seedOrg(orgId); + const bearer = makeTestBearer(accountId, organizationId); + await seedOrg(organizationId); const initializeResponse = await mcpPost({ bearer, @@ -568,14 +622,14 @@ describe("/mcp session restore", () => { }, 15_000); it("clears an existing session when live org access is revoked", async () => { - const orgId = `revoked_${nextOrgId()}`; + const organizationId = `revoked_${nextOrgId()}`; const accountId = nextAccountId(); const stub = env.MCP_SESSION.get(env.MCP_SESSION.newUniqueId()); const sessionId = stub.id.toString(); await runInDurableObject(stub, async (_instance, state) => { await state.storage.put(SESSION_META_KEY, { - organizationId: orgId, + organizationId, organizationName: "Revoked Org", userId: accountId, }); @@ -584,7 +638,7 @@ describe("/mcp session restore", () => { }); const revokedResponse = await mcpPost({ - bearer: makeTestBearer(accountId, orgId), + bearer: makeTestBearer(accountId, organizationId), sessionId, body: TOOLS_LIST_REQUEST, }); diff --git a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts index 7686b8086..f265274c8 100644 --- a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts +++ b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts @@ -33,7 +33,7 @@ import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { unstable_dev, type Unstable_DevWorker } from "wrangler"; import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing"; -import { makeTestBearer } from "./test-bearer"; +import { makeTestBearer } from "./testing/test-bearer"; // --------------------------------------------------------------------------- // Upstream test API — declared once via Effect's `HttpApi` so the spec the @@ -116,7 +116,7 @@ const UpstreamLive = Layer.effect( // --------------------------------------------------------------------------- // Telemetry receiver — a node HTTP server on a random port that speaks -// OTLP/JSON. The Effect OTLPTraceExporter in `services/telemetry.ts` +// OTLP/JSON. The Effect OTLPTraceExporter in `observability/telemetry.ts` // posts JSON bodies to it (confirmed via // `@opentelemetry/exporter-trace-otlp-http` — `Content-Type: // application/json` + `JsonTraceSerializer`). We parse resourceSpans → @@ -276,7 +276,7 @@ const WorkerLive = Layer.effect(Worker)( // become observable in the test process. return yield* Effect.acquireRelease( Effect.promise(() => - unstable_dev(resolve(__dirname, "./test-worker.ts"), { + unstable_dev(resolve(__dirname, "./testing/test-worker.ts"), { config: resolve(__dirname, "../wrangler.miniflare.jsonc"), experimental: { disableExperimentalWarning: true }, ip: "127.0.0.1", @@ -465,7 +465,14 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) "https://test-resource.example.com/.well-known/oauth-protected-resource/mcp", ); const body = yield* Effect.promise(() => response.json()); - expect(body).toEqual({ error: "unauthorized" }); + // Envelope canonicalizes the 401 body to a JSON-RPC error; the + // WWW-Authenticate challenge (asserted above) stays byte-for-byte via + // the provider's reason-sensitive Unauthorized.challenge. + expect(body).toEqual({ + jsonrpc: "2.0", + error: { code: -32001, message: "Unauthorized" }, + id: null, + }); }), 30_000, ); @@ -475,10 +482,10 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) () => Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; - const orgId = nextOrgId(); - yield* Effect.promise(() => seedOrg(orgId, "Miniflare Org")); + const organizationId = nextOrgId(); + yield* Effect.promise(() => seedOrg(organizationId, "Miniflare Org")); const client = yield* Effect.promise(() => - connectClient(baseUrl, makeTestBearer(nextAccountId(), orgId)), + connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId)), ); expect(client.getServerVersion()?.name).toBe("executor"); yield* Effect.promise(() => client.close()); @@ -491,10 +498,10 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) () => Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; - const orgId = nextOrgId(); - yield* Effect.promise(() => seedOrg(orgId, "List Tools Org")); + const organizationId = nextOrgId(); + yield* Effect.promise(() => seedOrg(organizationId, "List Tools Org")); const client = yield* Effect.promise(() => - connectClient(baseUrl, makeTestBearer(nextAccountId(), orgId)), + connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId)), ); const { tools } = yield* Effect.promise(() => client.listTools()); expect(tools.map((t) => t.name)).toContain("execute"); @@ -508,10 +515,10 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) () => Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; - const orgId = nextOrgId(); - yield* Effect.promise(() => seedOrg(orgId, "Execute Org")); + const organizationId = nextOrgId(); + yield* Effect.promise(() => seedOrg(organizationId, "Execute Org")); const client = yield* Effect.promise(() => - connectClient(baseUrl, makeTestBearer(nextAccountId(), orgId)), + connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId)), ); const result = yield* Effect.promise(() => client.callTool({ name: "execute", arguments: { code: "return 1 + 2" } }), @@ -529,9 +536,9 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) () => Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; - const orgId = nextOrgId(); - const bearer = makeTestBearer(nextAccountId(), orgId); - yield* Effect.promise(() => seedOrg(orgId, "Duplicate SSE Org")); + const organizationId = nextOrgId(); + const bearer = makeTestBearer(nextAccountId(), organizationId); + yield* Effect.promise(() => seedOrg(organizationId, "Duplicate SSE Org")); const sessionId = yield* Effect.promise(() => initializeSession(baseUrl, bearer)); const getHeaders = { @@ -562,9 +569,9 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) () => Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; - const orgId = nextOrgId(); - const bearer = makeTestBearer(nextAccountId(), orgId); - yield* Effect.promise(() => seedOrg(orgId, "Invalid SSE Replacement Org")); + const organizationId = nextOrgId(); + const bearer = makeTestBearer(nextAccountId(), organizationId); + yield* Effect.promise(() => seedOrg(organizationId, "Invalid SSE Replacement Org")); const sessionId = yield* Effect.promise(() => initializeSession(baseUrl, bearer)); const getHeaders = { @@ -607,9 +614,9 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) () => Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; - const orgId = nextOrgId(); - const bearer = makeTestBearer(nextAccountId(), orgId); - yield* Effect.promise(() => seedOrg(orgId, "SSE Reconnect Churn Org")); + const organizationId = nextOrgId(); + const bearer = makeTestBearer(nextAccountId(), organizationId); + yield* Effect.promise(() => seedOrg(organizationId, "SSE Reconnect Churn Org")); const sessionId = yield* Effect.promise(() => initializeSession(baseUrl, bearer)); const getHeaders = { @@ -688,9 +695,9 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) () => Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; - const orgId = nextOrgId(); - const bearer = makeTestBearer(nextAccountId(), orgId); - yield* Effect.promise(() => seedOrg(orgId, "Overlapping Request Id Org")); + const organizationId = nextOrgId(); + const bearer = makeTestBearer(nextAccountId(), organizationId); + yield* Effect.promise(() => seedOrg(organizationId, "Overlapping Request Id Org")); const sessionId = yield* Effect.promise(() => initializeSession(baseUrl, bearer)); const postExecute = (code: string) => @@ -757,11 +764,11 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; const { baseUrl: upstreamBaseUrl, specJson } = yield* Upstream; - const orgId = nextOrgId(); - yield* Effect.promise(() => seedOrg(orgId, "Elicit Org")); + const organizationId = nextOrgId(); + yield* Effect.promise(() => seedOrg(organizationId, "Elicit Org")); const client = yield* Effect.promise(() => - connectClient(baseUrl, makeTestBearer(nextAccountId(), orgId), { + connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId), { withElicitation: true, elicitationMode: "native", }), @@ -807,10 +814,10 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; const receiver = yield* TelemetryReceiver; - const orgId = nextOrgId(); - yield* Effect.promise(() => seedOrg(orgId, "Telemetry Org")); + const organizationId = nextOrgId(); + yield* Effect.promise(() => seedOrg(organizationId, "Telemetry Org")); const client = yield* Effect.promise(() => - connectClient(baseUrl, makeTestBearer(nextAccountId(), orgId)), + connectClient(baseUrl, makeTestBearer(nextAccountId(), organizationId)), ); // Trigger the DO through a multi-step flow so we can assert that // handleRequest spans are reported for every DO hit, not just init. @@ -850,17 +857,17 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP request-id telemetry", (it) => { Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; const receiver = yield* TelemetryReceiver; - const orgId = nextOrgId(); + const organizationId = nextOrgId(); const accountId = nextAccountId(); const requestId = `req_${crypto.randomUUID().replace(/-/g, "")}`; - yield* Effect.promise(() => seedOrg(orgId, "Request Id Org")); + yield* Effect.promise(() => seedOrg(organizationId, "Request Id Org")); const response = yield* Effect.promise(() => fetch(new URL("/mcp", baseUrl), { method: "POST", headers: { accept: "application/json, text/event-stream", - authorization: `Bearer ${makeTestBearer(accountId, orgId)}`, + authorization: `Bearer ${makeTestBearer(accountId, organizationId)}`, "content-type": "application/json", }, body: JSON.stringify({ diff --git a/apps/cloud/src/mcp-session.e2e.node.test.ts b/apps/cloud/src/mcp-session.e2e.node.test.ts index 7d7c5c488..c49955795 100644 --- a/apps/cloud/src/mcp-session.e2e.node.test.ts +++ b/apps/cloud/src/mcp-session.e2e.node.test.ts @@ -3,7 +3,7 @@ // The `McpSessionDO` in mcp-session.ts wires several things that previously // had zero integration coverage: // - `createScopedExecutor` against a real FumaDB/Drizzle handle (the 2026-04-16 -// prod outage was a schema spread bug here; see services/db.schema.test.ts) +// prod outage was a schema spread bug here; see db/db.schema.test.ts) // - `createExecutionEngine` with an in-process code executor // - `createExecutorMcpServer` for the MCP request surface // - Real `@modelcontextprotocol/sdk` Client → server round-trips @@ -21,23 +21,23 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; -import { createExecutorMcpServer } from "@executor-js/host-mcp"; +import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; +import { collectTables } from "@executor-js/api/server"; import { ElicitationResponse, FormElicitation, Scope, ScopeId, - collectTables, createExecutor, definePlugin, } from "@executor-js/sdk"; import { FetchHttpClient } from "effect/unstable/http"; import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; import executorConfig from "../executor.config"; -import { DbService } from "./services/db"; -import { createDrizzleFumaDb } from "./services/fuma"; +import { DbService } from "./db/db"; +import { createDrizzleFumaDb } from "./db/fuma"; // --------------------------------------------------------------------------- // Test-only plugin: exposes one in-memory tool that elicits once. Lets the @@ -108,7 +108,7 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO : basePlugins; const fuma = createDrizzleFumaDb({ db, - tables: collectTables(plugins), + tables: collectTables(), namespace: "executor_cloud", provider: "postgresql", }); @@ -130,12 +130,12 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO // them connected to an in-memory MCP client. Shaped as an acquireRelease so // the transport teardown is guaranteed when the test scope closes. const openSession = ( - orgId: string, + organizationId: string, options: BuildOptions & { readonly caps?: ClientCapabilities } = {}, ) => Effect.acquireRelease( Effect.gen(function* () { - const executor = yield* buildScopedExecutor(orgId, `Org ${orgId}`, options); + const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options); const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); const mcpServer = yield* createExecutorMcpServer({ engine, diff --git a/apps/cloud/src/mcp.ts b/apps/cloud/src/mcp.ts deleted file mode 100644 index 6057b688d..000000000 --- a/apps/cloud/src/mcp.ts +++ /dev/null @@ -1,877 +0,0 @@ -// --------------------------------------------------------------------------- -// Cloud MCP handler — Effect-native HTTP app for /mcp + /.well-known/* -// --------------------------------------------------------------------------- -// -// Built on Effect v4's unstable HTTP `HttpEffect.toWebHandler`. start.ts's -// mcpRequestMiddleware calls `mcpFetch` and falls through to `next()` when it -// returns `null` (non-MCP path) so TanStack Start keeps routing. -// -// Streaming passthrough — the MCP session Durable Object returns a `Response` -// whose body is a `ReadableStream` (SSE). We wrap that `Response` in -// `HttpServerResponse.raw(response)`; the platform's `toWeb` conversion -// recognises `body.body instanceof Response` and returns it as-is (only -// merging headers we set on the outer response, which is none), so the -// underlying `ReadableStream` passes through untouched. -// --------------------------------------------------------------------------- - -import { env } from "cloudflare:workers"; -import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { Cause, Context, Effect, Layer, Match, Option, Predicate, Result, Schema } from "effect"; - -import { createCachedRemoteJWKSet } from "./jwks-cache"; -import { captureCause } from "./observability"; -import { TelemetryLive } from "./services/telemetry"; -import { - McpJwtVerificationError, - verifyWorkOSMcpAccessToken, - type VerifiedToken, -} from "./mcp-auth"; -import { ApiKeyService } from "./auth/api-keys"; -import { authorizeOrganization } from "./auth/authorize-organization"; -import { UserStoreService } from "./auth/context"; -import { CoreSharedServices } from "./api/core-shared-services"; -import { DbService } from "./services/db"; -import { peekAndAnnotate } from "./mcp/response-peek"; -import { - authTemporarilyUnavailable, - CORS_ALLOW_ORIGIN, - jsonResponse, - jsonRpcError, - unauthorized, -} from "./mcp/responses"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh"; -const RESOURCE_ORIGIN = env.MCP_RESOURCE_ORIGIN ?? "https://executor.sh"; -const WORKOS_CLIENT_ID = env.WORKOS_CLIENT_ID; - -// Module-scope cache survives across MCP requests within the same worker -// isolate. AuthKit's JWKS rotates on the order of hours/days, so a 1h TTL -// dominates the upstream cooldown without sacrificing rotation safety — -// `createCachedRemoteJWKSet` force-refreshes on key-not-found inside its -// resolver. Production telemetry showed ~222 fetches/8h with p99 1.7s on -// the previous default-cooldown setup; this collapses that to ~1 per -// isolate-hour. -const jwks = createCachedRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)); - -const BEARER_PREFIX = "Bearer "; -const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; -const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; - -const CORS_PREFLIGHT_HEADERS = { - ...CORS_ALLOW_ORIGIN, - "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", - "access-control-allow-headers": - "authorization, content-type, mcp-session-id, accept, mcp-protocol-version", - "access-control-expose-headers": "mcp-session-id", -} as const; - -const TRUE_QUERY_VALUES = new Set(["1", "true", "yes", "on"]); - -const MCP_PATH = "/mcp"; -const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; -const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; -const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; - -// Org-scoped variants: `/org_xxx/mcp` lets a client pin a specific org without -// relying on the token's `org_id` claim. The org is taken from the URL and -// re-checked against live WorkOS membership per request, so the URL is a -// selector, not a trust boundary. (When web routes move under `/:org`, this -// becomes the handle form; for now we accept the raw WorkOS org id.) -const resourceUrlFor = (organizationId: string | null): string => - organizationId ? `${RESOURCE_ORIGIN}/${organizationId}${MCP_PATH}` : RESOURCE_URL; - -const protectedResourceMetadataUrlFor = (organizationId: string | null): string => - organizationId - ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationId}/mcp` - : PROTECTED_RESOURCE_METADATA_URL; - -type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; - -type McpAuthorizedResult = { - readonly _tag: "Authorized"; - readonly token: VerifiedToken; -}; - -type McpUnauthorizedResult = { - readonly _tag: "Unauthorized"; - readonly reason: McpUnauthorizedReason; - readonly description?: string; -}; - -export type McpAuthResult = McpAuthorizedResult | McpUnauthorizedResult; - -export const mcpAuthorized = (token: VerifiedToken): McpAuthorizedResult => ({ - _tag: "Authorized", - token, -}); - -export const mcpUnauthorized = ( - reason: McpUnauthorizedReason, - description?: string, -): McpUnauthorizedResult => ({ - _tag: "Unauthorized", - reason, - description, -}); - -const corsPreflight = HttpServerResponse.empty({ - status: 204, - headers: CORS_PREFLIGHT_HEADERS, -}); - -// --------------------------------------------------------------------------- -// Auth -// --------------------------------------------------------------------------- - -export class McpAuth extends Context.Service< - McpAuth, - { - readonly verifyBearer: ( - request: Request, - ) => Effect.Effect; - } ->()("@executor-js/cloud/McpAuth") {} - -export class McpOrganizationAuth extends Context.Service< - McpOrganizationAuth, - { - readonly authorize: ( - accountId: string, - organizationId: string, - ) => Effect.Effect; - } ->()("@executor-js/cloud/McpOrganizationAuth") {} - -const verifyJwt = (token: string) => - verifyWorkOSMcpAccessToken(token, jwks, { - issuer: AUTHKIT_DOMAIN, - audience: WORKOS_CLIENT_ID, - }); - -const DbLive = DbService.Live; -const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); -const McpOrganizationAuthServices = Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); - -export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ - authorize: (accountId, organizationId) => - authorizeOrganization(accountId, organizationId).pipe( - Effect.map((org) => org !== null), - Effect.provide(McpOrganizationAuthServices), - ), -}); - -const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; - -export const McpAuthLive = Layer.effect( - McpAuth, - Effect.gen(function* () { - const apiKeys = yield* ApiKeyService; - - const verifyApiKey = Effect.fn("mcp.auth.verify_api_key")(function* (token: string) { - const principal = yield* apiKeys.validate(token).pipe( - Effect.catchTag("ApiKeyValidationError", (error) => - Effect.fail( - new McpJwtVerificationError({ - cause: error.cause, - reason: "system", - }), - ), - ), - ); - if (!principal) { - yield* Effect.annotateCurrentSpan({ - "mcp.auth.outcome": "invalid", - "mcp.auth.invalid_reason": "api_key", - }); - return mcpUnauthorized("invalid_token", "The API key is invalid"); - } - - yield* Effect.annotateCurrentSpan({ - "mcp.auth.outcome": "verified", - "mcp.auth.credential_type": "api_key", - "mcp.auth.has_organization": true, - }); - return mcpAuthorized({ - accountId: principal.accountId, - organizationId: principal.organizationId, - }); - }); - - const verifyJwtBearer = Effect.fn("mcp.auth.verify_jwt_bearer")(function* (token: string) { - const verified = yield* verifyJwt(token).pipe( - Effect.catchTag("McpJwtVerificationError", (error) => { - if (error.reason === "system") return Effect.fail(error); - return Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "mcp.auth.outcome": "invalid", - "mcp.auth.invalid_reason": error.reason, - }); - return mcpUnauthorized( - "invalid_token", - error.reason === "expired" - ? "The access token expired" - : "The access token is invalid", - ); - }); - }), - ); - if (!verified) return mcpUnauthorized("invalid_token", "The access token is invalid"); - if (Predicate.isTagged(verified, "Unauthorized")) return verified; - if (!verified.accountId) { - yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_subject" }); - return mcpUnauthorized("invalid_token", "The access token is invalid"); - } - yield* Effect.annotateCurrentSpan({ - "mcp.auth.outcome": "verified", - "mcp.auth.credential_type": "jwt", - "mcp.auth.has_organization": !!verified.organizationId, - }); - return mcpAuthorized(verified); - }); - - return { - verifyBearer: Effect.fn("mcp.auth.verify_bearer")(function* (request) { - const authHeader = request.headers.get("authorization"); - if (!authHeader?.startsWith(BEARER_PREFIX)) { - yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_bearer" }); - return mcpUnauthorized("missing_bearer"); - } - const token = authHeader.slice(BEARER_PREFIX.length).trim(); - if (!token) return mcpUnauthorized("invalid_token", "The bearer token is invalid"); - return yield* looksLikeJwt(token) ? verifyJwtBearer(token) : verifyApiKey(token); - }), - }; - }), -); - -// --------------------------------------------------------------------------- -// Client fingerprint capture -// --------------------------------------------------------------------------- -// Annotates the Effect span with everything we can learn about a connecting MCP client: the -// parsed JSON-RPC body, whitelisted request headers, CF request metadata, -// and verified-JWT claims. Lets us compare how each client (Claude Code, -// Claude.ai web, ChatGPT, custom scripts, ...) actually reports over the -// wire. Runs before dispatch so unauthorized requests still get fingerprinted. -// --------------------------------------------------------------------------- - -type CfRequestMetadata = { - country?: string; - city?: string; - region?: string; - timezone?: string; - asn?: number; - asOrganization?: string; - tlsVersion?: string; - tlsCipher?: string; - httpProtocol?: string; - colo?: string; -}; - -const requestWithCf = (request: Request): Request & { cf?: CfRequestMetadata } => - request as Request & { cf?: CfRequestMetadata }; - -const getCfMeta = (request: Request): CfRequestMetadata => requestWithCf(request).cf ?? {}; - -const HEADERS_TO_DUMP = [ - "accept", - "accept-encoding", - "accept-language", - "cache-control", - "content-type", - "mcp-protocol-version", - "origin", - "referer", - "sec-fetch-dest", - "sec-fetch-mode", - "sec-fetch-site", - "user-agent", - "x-client-name", - "x-client-version", - "x-requested-with", -] as const; - -const dumpHeaders = (request: Request): Record => { - const out: Record = {}; - for (const name of HEADERS_TO_DUMP) { - const value = request.headers.get(name); - if (value !== null) out[`mcp.http.header.${name}`] = value; - } - const authHeader = request.headers.get("authorization"); - if (authHeader) { - out["mcp.http.header.authorization.scheme"] = authHeader.split(" ", 1)[0] ?? ""; - out["mcp.http.header.authorization.length"] = String(authHeader.length); - } - // Record the full header name list too — surfaces anything unexpected - // without us having to enumerate every possibility up front. - out["mcp.http.header.names"] = Array.from(request.headers.keys()).sort().join(","); - return out; -}; - -// JSON-RPC shapes — narrow to just the fields we fingerprint. Using Schema -// collapses the typeof-guard pile and surfaces "what does an MCP client -// actually send us" as declarative types. Unknown/malformed input decodes -// to None and contributes no span attrs. - -const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); - -const JsonRpcEnvelope = Schema.Struct({ - method: Schema.optional(Schema.String), - id: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Null])), - params: Schema.optional(UnknownRecord), - // Responses to server-initiated requests arrive as POST bodies too — - // notably elicitation replies (`result.action = "accept" | "decline" | "cancel"`). - result: Schema.optional(UnknownRecord), -}); -type JsonRpcEnvelope = typeof JsonRpcEnvelope.Type; - -const ElicitationReplyResult = Schema.Struct({ - action: Schema.optional(Schema.Literals(["accept", "decline", "cancel"])), -}); - -const InitializeParams = Schema.Struct({ - protocolVersion: Schema.optional(Schema.String), - clientInfo: Schema.optional( - Schema.Struct({ - name: Schema.optional(Schema.String), - version: Schema.optional(Schema.String), - title: Schema.optional(Schema.String), - }), - ), - capabilities: Schema.optional(UnknownRecord), -}); - -const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); -const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); - -const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( - Schema.fromJsonString(JsonRpcEnvelope), -); -const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); -const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); -const decodeUriParams = Schema.decodeUnknownOption(UriParams); -const decodeElicitationReplyResult = Schema.decodeUnknownOption(ElicitationReplyResult); - -const isMcpAuthorized = (value: McpAuthResult): value is McpAuthorizedResult => - Predicate.isTagged(value, "Authorized"); -const isMcpUnauthorized = (value: McpAuthResult): value is McpUnauthorizedResult => - Predicate.isTagged(value, "Unauthorized"); - -const readJsonRpcEnvelope = (request: Request): Effect.Effect> => - Effect.tryPromise({ - try: () => request.clone().text(), - catch: () => undefined, - }).pipe( - Effect.map((text) => (text ? decodeJsonRpcEnvelopeString(text) : Option.none())), - Effect.catchCause(() => Effect.succeed(Option.none())), - Effect.withSpan("mcp.request.read_json_rpc"), - ); - -const methodAttrs = (envelope: JsonRpcEnvelope): Record => { - const params = envelope.params ?? {}; - return Match.value(envelope.method).pipe( - Match.when("initialize", () => - Option.match(decodeInitializeParams(params), { - onNone: () => ({}) as Record, - onSome: (init) => ({ - ...(init.protocolVersion && { "mcp.client.protocol_version": init.protocolVersion }), - ...(init.clientInfo?.name && { "mcp.client.name": init.clientInfo.name }), - ...(init.clientInfo?.version && { "mcp.client.version": init.clientInfo.version }), - ...(init.clientInfo?.title && { "mcp.client.title": init.clientInfo.title }), - "mcp.client.capability.keys": Object.keys(init.capabilities ?? {}) - .sort() - .join(","), - }), - }), - ), - Match.when("tools/call", () => - Option.match(decodeNamedParams(params), { - onNone: () => ({}) as Record, - onSome: ({ name }) => (name ? { "mcp.tool.name": name } : {}), - }), - ), - Match.whenOr("resources/read", "resources/subscribe", () => - Option.match(decodeUriParams(params), { - onNone: () => ({}) as Record, - onSome: ({ uri }) => (uri ? { "mcp.resource.uri": uri } : {}), - }), - ), - Match.when("prompts/get", () => - Option.match(decodeNamedParams(params), { - onNone: () => ({}) as Record, - onSome: ({ name }) => (name ? { "mcp.prompt.name": name } : {}), - }), - ), - Match.option, - Option.getOrElse(() => ({}) as Record), - ); -}; - -const replyAttrs = (envelope: JsonRpcEnvelope): Record => { - if (!envelope.result || envelope.method) return {}; - return Option.match(decodeElicitationReplyResult(envelope.result), { - onNone: () => ({}), - onSome: ({ action }) => (action ? { "mcp.elicitation.action": action } : {}), - }); -}; - -const rpcAttrs = (envelope: Option.Option): Record => - Option.match(envelope, { - onNone: () => ({}), - onSome: (e) => ({ - ...(e.method && { "mcp.rpc.method": e.method }), - ...(e.id !== undefined && e.id !== null && { "mcp.rpc.id": String(e.id) }), - ...methodAttrs(e), - ...replyAttrs(e), - }), - }); - -const annotateMcpRequest = ( - request: Request, - opts: { token: VerifiedToken | null; parseBody: boolean }, -): Effect.Effect => - Effect.gen(function* () { - const cf = getCfMeta(request); - const baseAttrs: Record = { - "mcp.request.method": request.method, - "mcp.request.session_id_present": !!request.headers.get("mcp-session-id"), - "mcp.request.session_id": request.headers.get("mcp-session-id") ?? "", - "mcp.auth.has_bearer": (request.headers.get("authorization") ?? "").startsWith(BEARER_PREFIX), - "mcp.auth.verified": !!opts.token, - "mcp.auth.organization_id": opts.token?.organizationId ?? "", - "mcp.auth.account_id": opts.token?.accountId ?? "", - "cf.country": cf.country ?? "", - "cf.city": cf.city ?? "", - "cf.region": cf.region ?? "", - "cf.timezone": cf.timezone ?? "", - "cf.asn": cf.asn ?? 0, - "cf.as_organization": cf.asOrganization ?? "", - "cf.tls_version": cf.tlsVersion ?? "", - "cf.tls_cipher": cf.tlsCipher ?? "", - "cf.http_protocol": cf.httpProtocol ?? "", - "cf.colo": cf.colo ?? "", - ...dumpHeaders(request), - }; - - const envelope = opts.parseBody ? yield* readJsonRpcEnvelope(request) : Option.none(); - const attrs = { - ...baseAttrs, - ...rpcAttrs(envelope), - "mcp.request.parse_body": opts.parseBody, - }; - - yield* Effect.annotateCurrentSpan(attrs); - yield* Effect.annotateCurrentSpan(attrs).pipe(Effect.withSpan("mcp.request.annotate")); - }); - -// --------------------------------------------------------------------------- -// OAuth metadata endpoints -// --------------------------------------------------------------------------- - -const protectedResourceMetadata = (organizationId: string | null) => - Effect.sync(() => - jsonResponse({ - resource: resourceUrlFor(organizationId), - authorization_servers: [AUTHKIT_DOMAIN], - bearer_methods_supported: ["header"], - scopes_supported: [], - }), - ); - -const authorizationServerMetadata = Effect.tryPromise({ - try: async () => { - const res = await fetch(`${AUTHKIT_DOMAIN}/.well-known/oauth-authorization-server`); - if (!res.ok) return jsonResponse({ error: "upstream_error" }, 502); - return jsonResponse(await res.json()); - }, - catch: () => undefined, -}).pipe(Effect.catchCause(() => Effect.succeed(jsonResponse({ error: "upstream_error" }, 502)))); - -// --------------------------------------------------------------------------- -// DO dispatch -// --------------------------------------------------------------------------- - -// Worker and DO run in separate isolates with independent WebSdk tracer -// providers. Neither one can see the other's OTEL context, so the DO used -// to emit a brand-new root trace on every stub call. Ferry the worker span -// context across with W3C headers: `traceparent` generated from the active -// Effect span plus passthrough `tracestate` / `baggage` from the inbound -// request. -type IncomingPropagationHeaders = { - readonly traceparent?: string; - readonly tracestate?: string; - readonly baggage?: string; -}; - -const currentTraceparent = Effect.map(Effect.currentSpan, (span) => { - if (!span || !span.traceId || !span.spanId) return undefined; - const flags = span.sampled ? "01" : "00"; - return `00-${span.traceId}-${span.spanId}-${flags}`; -}).pipe(Effect.orElseSucceed(() => undefined)); - -const currentPropagationHeaders = (request: Request): Effect.Effect => - Effect.map(currentTraceparent, (traceparent) => ({ - traceparent, - tracestate: request.headers.get("tracestate") ?? undefined, - baggage: request.headers.get("baggage") ?? undefined, - })); - -const withPropagationHeaders = ( - request: Request, - propagation: IncomingPropagationHeaders, -): Request => { - const headers = new Headers(request.headers); - if (propagation.traceparent) { - headers.set("traceparent", propagation.traceparent); - } - if (propagation.tracestate) { - headers.set("tracestate", propagation.tracestate); - } - if (propagation.baggage) { - headers.set("baggage", propagation.baggage); - } - return new Request(request, { headers }); -}; - -const withVerifiedIdentityHeaders = ( - request: Request, - accountId: string, - organizationId: string, -): Request => { - const headers = new Headers(request.headers); - headers.set(INTERNAL_ACCOUNT_ID_HEADER, accountId); - headers.set(INTERNAL_ORGANIZATION_ID_HEADER, organizationId); - return new Request(request, { headers }); -}; - -const withMcpResponseHeaders = (response: Response): Response => { - const headers = new Headers(response.headers); - headers.set("access-control-allow-origin", "*"); - headers.set("access-control-expose-headers", "mcp-session-id"); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -}; - -type McpElicitationMode = "browser" | "model" | "native"; - -const MCP_ELICITATION_MODES = new Set(["browser", "model", "native"]); - -const readElicitationMode = (request: Request): McpElicitationMode => { - const url = new URL(request.url); - const mode = url.searchParams.get("elicitation_mode"); - if (mode && MCP_ELICITATION_MODES.has(mode as McpElicitationMode)) { - return mode as McpElicitationMode; - } - - const legacyModelResume = url.searchParams.get("allow_model_resume"); - if (legacyModelResume !== null && TRUE_QUERY_VALUES.has(legacyModelResume.toLowerCase())) { - return "model"; - } - - return "model"; -}; - -/** - * Forward a request to an existing session DO. Wrapping the DO's `Response` - * with `HttpServerResponse.raw` lets streaming bodies (SSE) pass through - * `HttpEffect.toWebHandler`'s conversion unchanged. - */ -const forwardToExistingSession = ( - request: Request, - sessionId: string, - peek: boolean, - token: VerifiedToken, - organizationId: string, -) => - Effect.gen(function* () { - const ns = env.MCP_SESSION; - const stub = ns.get(ns.idFromString(sessionId)); - const propagation = yield* currentPropagationHeaders(request); - const propagated = withPropagationHeaders( - withVerifiedIdentityHeaders(request, token.accountId, organizationId), - propagation, - ); - const raw = yield* Effect.promise( - () => stub.handleRequest(propagated) as Promise, - ).pipe( - Effect.withSpan("mcp.do.handle_request", { - attributes: { - "mcp.request.method": request.method, - "mcp.request.session_id_present": true, - }, - }), - ); - const annotated = peek ? yield* peekAndAnnotate(raw) : raw; - return HttpServerResponse.raw(withMcpResponseHeaders(annotated)); - }); - -const clearExistingSession = (request: Request, sessionId: string) => - Effect.gen(function* () { - const ns = env.MCP_SESSION; - const stub = ns.get(ns.idFromString(sessionId)); - const propagation = yield* currentPropagationHeaders(request); - yield* Effect.promise(() => stub.clearSession(propagation) as Promise).pipe( - Effect.catchCause(() => Effect.void), - Effect.withSpan("mcp.do.clear_session", { - attributes: { "mcp.request.session_id_present": true }, - }), - ); - }); - -const authorizeMcpOrganization = ( - request: Request, - token: VerifiedToken, - organizationId: string | null, - sessionId: string | null, -) => - Effect.gen(function* () { - if (!organizationId) { - return jsonRpcError(403, -32001, "No organization in session — log in via the web app first"); - } - - const auth = yield* McpOrganizationAuth; - const allowed = yield* auth.authorize(token.accountId, organizationId).pipe( - Effect.catchCause((error) => - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "mcp.auth.organization_authorize_error": Cause.pretty(error), - }); - return false; - }), - ), - Effect.withSpan("mcp.auth.authorize_organization", { - attributes: { "mcp.auth.organization_id": organizationId }, - }), - ); - if (allowed) return null; - - if (sessionId) { - yield* clearExistingSession(request, sessionId); - } - return jsonRpcError(403, -32001, "No organization in session — log in via the web app first"); - }); - -const dispatchPost = (request: Request, token: VerifiedToken, organizationId: string | null) => - Effect.gen(function* () { - const sessionId = request.headers.get("mcp-session-id"); - const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId); - if (authError) return authError; - const orgId = organizationId!; - - if (sessionId) return yield* forwardToExistingSession(request, sessionId, true, token, orgId); - - const ns = env.MCP_SESSION; - const stub = ns.get(ns.newUniqueId()); - const propagation = yield* currentPropagationHeaders(request); - yield* Effect.promise(() => - stub.init( - { - organizationId: orgId, - userId: token.accountId, - elicitationMode: readElicitationMode(request), - }, - propagation, - ), - ).pipe( - Effect.withSpan("mcp.do.init", { - attributes: { "mcp.request.session_id_present": false }, - }), - ); - const propagated = withPropagationHeaders( - withVerifiedIdentityHeaders(request, token.accountId, orgId), - propagation, - ); - const raw = yield* Effect.promise( - () => stub.handleRequest(propagated) as Promise, - ).pipe( - Effect.withSpan("mcp.do.handle_request", { - attributes: { - "mcp.request.method": request.method, - "mcp.request.session_id_present": false, - }, - }), - ); - const annotated = yield* peekAndAnnotate(raw); - return HttpServerResponse.raw(withMcpResponseHeaders(annotated)); - }); - -const dispatchGet = (request: Request, token: VerifiedToken, organizationId: string | null) => { - const sessionId = request.headers.get("mcp-session-id"); - if (!sessionId) - return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE")); - return Effect.gen(function* () { - const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId); - if (authError) return authError; - return yield* forwardToExistingSession(request, sessionId, false, token, organizationId!); - }); -}; - -const dispatchDelete = (request: Request, token: VerifiedToken, organizationId: string | null) => { - const sessionId = request.headers.get("mcp-session-id"); - if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 })); - return Effect.gen(function* () { - const authError = yield* authorizeMcpOrganization(request, token, organizationId, sessionId); - if (authError) return authError; - return yield* forwardToExistingSession(request, sessionId, true, token, organizationId!); - }); -}; - -// --------------------------------------------------------------------------- -// App -// --------------------------------------------------------------------------- - -type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server"; - -type McpRoute = { - readonly kind: McpRouteKind; - /** Org id pinned in the URL (`/org_xxx/mcp`), or `null` for the bare path. */ - readonly organizationId: string | null; -} | null; - -const PRM_PREFIX = "/.well-known/oauth-protected-resource"; - -// A path segment counts as an org selector only when it has the WorkOS org id -// shape (`org_…`). This keeps the MCP fall-through filter from claiming an -// unrelated `//mcp` path instead of letting TanStack route it. -const orgIdSegment = (segment: string | undefined): string | null => - segment && segment.startsWith("org_") ? segment : null; - -// Matches a trailing MCP endpoint — `mcp` (bare) or `/mcp`. Returns the org -// id, `null` for the bare form, or `undefined` when the segments are neither. -const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => { - if (segments.length === 1 && segments[0] === "mcp") return null; - if (segments.length === 2 && segments[1] === "mcp") return orgIdSegment(segments[0]) ?? undefined; - return undefined; -}; - -/** - * Returns the MCP route (kind + optional URL-pinned org) for a pathname, or - * `null` if the path isn't owned by the MCP handler. - * - * This is THE ownership predicate: `start.ts`'s request middleware uses it to - * decide whether to hand a request to `mcpFetch` (vs. fall through to TanStack - * Start), `mcpApp` uses it to dispatch, and the test worker reuses it too — so - * the set of MCP paths lives in exactly one place. - */ -export const classifyMcpPath = (pathname: string): McpRoute => { - if (pathname === "/.well-known/oauth-authorization-server") { - return { kind: "oauth-authorization-server", organizationId: null }; - } - const segments = pathname.split("/").filter((segment) => segment.length > 0); - - // Protected-resource metadata: `${PRM_PREFIX}/mcp` or `${PRM_PREFIX}//mcp`. - // The org sits after the well-known prefix (RFC 9728), not at the path root. - if (pathname.startsWith(`${PRM_PREFIX}/`)) { - const organizationId = matchMcpSuffix(segments.slice(2)); - return organizationId === undefined - ? null - : { kind: "oauth-protected-resource", organizationId }; - } - - // MCP transport: `/mcp` or `//mcp`. - const organizationId = matchMcpSuffix(segments); - return organizationId === undefined ? null : { kind: "mcp", organizationId }; -}; - -/** - * Raw Effect-native MCP app. Exported so alternate entry points (e.g. the - * vitest-pool-workers test worker) can provide their own auth layers because - * hitting WorkOS JWKS / membership APIs is not practical in the isolate. - */ -export const mcpApp: Effect.Effect< - HttpServerResponse.HttpServerResponse, - never, - HttpServerRequest.HttpServerRequest | McpAuth | McpOrganizationAuth -> = Effect.gen(function* () { - const httpRequest = yield* HttpServerRequest.HttpServerRequest; - const request = httpRequest.source as Request; - const route = classifyMcpPath(new URL(request.url).pathname); - const pathOrganizationId = route?.organizationId ?? null; - - if (request.method === "OPTIONS") return corsPreflight; - if (route?.kind === "oauth-protected-resource") { - return yield* protectedResourceMetadata(pathOrganizationId); - } - if (route?.kind === "oauth-authorization-server") return yield* authorizationServerMetadata; - - const auth = yield* McpAuth; - const authResult = yield* auth.verifyBearer(request).pipe(Effect.result); - - if (Result.isFailure(authResult)) { - yield* annotateMcpRequest(request, { - token: null, - parseBody: false, - }); - return yield* authTemporarilyUnavailable(authResult.failure); - } - const authValue = authResult.success; - - // Annotate before dispatch so even 401s show up with what we know. Only - // POST bodies are JSON-RPC payloads worth parsing; GET (SSE) and DELETE - // don't carry one. - yield* annotateMcpRequest(request, { - token: isMcpAuthorized(authValue) ? authValue.token : null, - parseBody: request.method === "POST" && isMcpAuthorized(authValue), - }); - - if (isMcpUnauthorized(authValue)) { - return unauthorized(authValue, protectedResourceMetadataUrlFor(pathOrganizationId)); - } - const token = authValue.token; - // URL is the source of truth for the active org when present (`/org_xxx/mcp`); - // the bare `/mcp` path falls back to the token's `org_id`. Either way the org - // is verified against live WorkOS membership in authorizeMcpOrganization. - const organizationId = pathOrganizationId ?? token.organizationId; - const dispatchEffect = Match.value(request.method).pipe( - Match.when("POST", () => dispatchPost(request, token, organizationId)), - Match.when("GET", () => dispatchGet(request, token, organizationId)), - Match.when("DELETE", () => dispatchDelete(request, token, organizationId)), - Match.option, - ); - if (Option.isSome(dispatchEffect)) { - return yield* dispatchEffect.value; - } - return jsonRpcError(405, -32001, "Method not allowed"); -}).pipe( - Effect.withSpan("mcp.request"), - Effect.catchCause((cause) => - Effect.sync(() => { - console.error("[mcp] request failed:", Cause.pretty(cause)); - captureCause(cause); - return jsonRpcError(500, -32603, "Internal server error"); - }), - ), -); - -const rawMcpFetch = HttpEffect.toWebHandler( - mcpApp.pipe( - Effect.provide( - Layer.mergeAll( - McpAuthLive.pipe( - Layer.provide(ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices))), - ), - McpOrganizationAuthLive, - TelemetryLive, - ), - ), - ), -); - -/** - * Fetch handler for /mcp + /.well-known/* paths. - * - * Returns `null` when the path doesn't match a known MCP route so the caller - * (`start.ts`'s mcpRequestMiddleware) can fall through to `next()` and let - * TanStack Start handle normal routing — e.g. an unknown `/.well-known/*` - * path that should 404 through the regular route tree. - */ -export const mcpFetch = async (request: Request): Promise => { - if (classifyMcpPath(new URL(request.url).pathname) === null) return null; - return rawMcpFetch(request); -}; diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts new file mode 100644 index 000000000..33b676f9e --- /dev/null +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -0,0 +1,221 @@ +// --------------------------------------------------------------------------- +// Cloud McpAuthProvider adapter — the cloud analog of selfHostMcpAuthProviderLayer. +// +// Folds the entire cloud edge auth/authz surface (WorkOS JWT verify + API-key +// bearer + per-request org-liveness check + the two OAuth discovery docs) into +// ONE `McpAuthProvider` Layer behind the shared host-mcp envelope. +// +// `authenticate(request)` runs on EVERY /mcp request and resolves a typed +// AuthOutcome: +// - missing bearer -> Unauthorized (challenge: Bearer resource_metadata=…) +// - invalid token/api key -> Unauthorized (challenge: Bearer error="invalid_token" …) +// - transient JWKS infra -> Unavailable (caught here; envelope renders 503 -32001) +// - no org / revoked org -> Forbidden ("No organization in session …", -32001). +// Because authenticate reads the mcp-session-id header to do the live org +// check, the envelope's dispose-on-Forbidden-with-sessionId path reproduces +// the old inline clearExistingSession. +// - verified + org allowed -> Authenticated(principal) +// +// The rich `mcp.request.annotate` client-fingerprint span (cloud-specific, no +// envelope seam) is emitted from here so telemetry parity is preserved. +// +// The OAuth endpoints (/authorize, /token, /register) are NOT cloud's — they +// live at WorkOS/AuthKit (external); only the two discovery docs are mounted. +// --------------------------------------------------------------------------- + +import { Cause, Effect, Layer, Predicate, Result } from "effect"; + +import { + authenticated, + forbidden, + unauthorized, + unavailable, + McpAuthProvider, + type AuthOutcome, + type McpDiscoveryRoute, + type Principal, +} from "@executor-js/host-mcp"; + +import { ApiKeyService } from "../auth/api-keys"; +import { CoreSharedServices } from "../auth/workos"; +import { + bearerChallengeFor, + mcpOrganizationFromRequest, + protectedResourceMetadataUrlFor, + PROTECTED_RESOURCE_METADATA_PATH, + McpAuth, + McpAuthLive, + McpOrganizationAuth, + McpOrganizationAuthLive, + type McpAuthResult, + type VerifiedToken, +} from "./auth"; +import { annotateMcpRequest } from "./telemetry"; +import { + authorizationServerMetadataResponse, + protectedResourceMetadataResponse, +} from "./oauth-metadata"; + +const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; + +const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; + +/** + * Enrich a cloud {@link VerifiedToken} (which carries only accountId + + * organizationId) into the full {@link Principal} the seam validates. The + * envelope only uses `accountId` + `organizationId` for ownership; cloud + * resolves org name/email inside the DO, so the cosmetic identity fields carry + * empty placeholders. `organizationId` is guaranteed non-null here because the + * Forbidden branch already rejected the no-org case before Authenticated. + */ +const principalFromToken = (token: VerifiedToken, organizationId: string): Principal => ({ + accountId: token.accountId, + organizationId, + organizationName: "", + email: "", + name: null, + avatarUrl: null, + roles: [], +}); + +export const cloudMcpAuthProviderLayer: Layer.Layer< + McpAuthProvider, + never, + McpAuth | McpOrganizationAuth +> = Layer.effect( + McpAuthProvider, + Effect.gen(function* () { + const auth = yield* McpAuth; + const orgAuth = yield* McpOrganizationAuth; + + const discoveryRoutes: ReadonlyArray = [ + { + path: PROTECTED_RESOURCE_METADATA_PATH, + // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an + // org-scoped discovery doc onto it and pins the org in the header we read. + handler: (request) => + Effect.succeed(protectedResourceMetadataResponse(mcpOrganizationFromRequest(request))), + }, + { + path: AUTHORIZATION_SERVER_METADATA_PATH, + handler: () => authorizationServerMetadataResponse, + }, + ]; + + const resourceMetadataUrl = (request: Request): string => + protectedResourceMetadataUrlFor(mcpOrganizationFromRequest(request)); + + /** + * Resolve a verified bearer to a final AuthOutcome by running the live org + * check. Mirrors the old `authorizeMcpOrganization`: no org -> Forbidden; + * revoked live org -> Forbidden (the envelope disposes the session when a + * session-id is present). Telemetry-annotates before returning so even 401s + * and 403s carry the client fingerprint. + */ + const finishAuthorized = (request: Request, token: VerifiedToken): Effect.Effect => + Effect.gen(function* () { + // OLD `mcpApp` annotated with parseBody = (POST && isAuthorized) BEFORE + // org-authz, so a verified-but-no/revoked-org POST still captured + // mcp.rpc.method/id. The body is read via `request.clone().text()` + // (annotateMcpRequest -> readJsonRpcEnvelope), so it never consumes the + // original stream a downstream dispatch reads — safe on every path, + // including the Forbidden short-circuit. Keep parseBody keyed on POST, + // not on the org outcome, to preserve that telemetry. + const parseBody = request.method === "POST"; + + // URL is the source of truth for the active org when pinned (`/org_xxx/mcp`, + // carried in the header by `prepareMcpOrgScope`); the bare `/mcp` falls back + // to the token's `org_id`. Either way `orgAuth.authorize` re-checks live + // WorkOS membership below, so the URL is a selector, not a trust boundary. + const organizationId = mcpOrganizationFromRequest(request) ?? token.organizationId; + if (!organizationId) { + yield* annotateMcpRequest(request, { token, parseBody }); + return forbidden(NO_ORGANIZATION_MESSAGE, -32001); + } + + const allowed = yield* orgAuth.authorize(token.accountId, organizationId).pipe( + Effect.catchCause((error) => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "mcp.auth.organization_authorize_error": Cause.pretty(error), + }); + return false; + }), + ), + Effect.withSpan("mcp.auth.authorize_organization", { + attributes: { "mcp.auth.organization_id": organizationId }, + }), + ); + + yield* annotateMcpRequest(request, { token, parseBody }); + + if (!allowed) return forbidden(NO_ORGANIZATION_MESSAGE, -32001); + return authenticated(principalFromToken(token, organizationId)); + }); + + const toOutcome = (request: Request, result: McpAuthResult): Effect.Effect => { + if (Predicate.isTagged(result, "Authorized")) { + return finishAuthorized(request, result.token); + } + return annotateMcpRequest(request, { token: null, parseBody: false }).pipe( + Effect.as(unauthorized(bearerChallengeFor(result, mcpOrganizationFromRequest(request)))), + ); + }; + + /** + * Never fails: a transient JWKS-infra failure (the McpJwtVerificationError + * the old `mcpApp` caught and turned into a 503) is caught HERE and mapped + * to Unavailable so the envelope renders the retryable 503 -32001. + */ + const authenticate = (request: Request): Effect.Effect => + auth.verifyBearer(request).pipe( + Effect.result, + Effect.flatMap((result) => + Result.isFailure(result) + ? annotateMcpRequest(request, { token: null, parseBody: false }).pipe( + Effect.flatMap(() => + Effect.annotateCurrentSpan({ + "mcp.auth.outcome": "system_error", + "mcp.auth.system_error.reason": result.failure.reason, + "mcp.auth.system_error.message": String(result.failure.cause).slice(0, 500), + }), + ), + Effect.as(unavailable("Authentication temporarily unavailable - please retry")), + ) + : toOutcome(request, result.success), + ), + Effect.withSpan("mcp.request"), + ); + + return { + discoveryRoutes, + resourceMetadataUrl, + authenticate, + }; + }), +); + +// --------------------------------------------------------------------------- +// The cloud MCP auth seam fed to `ExecutorApp.make`'s `mcp.auth` slot. +// +// `make`'s MCP seam contract is generic over the auth seam's residual +// (`Layer`). Cloud's MCP auth is a SEPARATE +// credential plane (WorkOS JWT + API-key bearer, no cookie session), so it does +// NOT read the neutral `IdentityProvider` fallback the way self-host does; it +// provides its own `McpAuth` + `McpOrganizationAuth` seams INTERNALLY (the +// production WorkOS JWT verify over `ApiKeyService.WorkOS` + live org-liveness), +// so `RMcpAuth = never` — no phantom requirement, no cast. (Self-host's seam +// genuinely requires `IdentityProvider`, so its `RMcpAuth = IdentityProvider`.) +// --------------------------------------------------------------------------- +export const cloudMcpAuth: Layer.Layer = cloudMcpAuthProviderLayer.pipe( + Layer.provide( + Layer.mergeAll( + McpAuthLive.pipe(Layer.provide(ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices)))), + McpOrganizationAuthLive, + ), + ), + // A boot-time WorkOS misconfiguration (the `WorkOSClient.Default` config error) + // is unrecoverable; die rather than leak it into the seam's channel. + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a boot-time WorkOS misconfiguration is unrecoverable + Layer.orDie, +); diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts new file mode 100644 index 000000000..79a6e7d33 --- /dev/null +++ b/apps/cloud/src/mcp/auth.ts @@ -0,0 +1,247 @@ +// --------------------------------------------------------------------------- +// Cloud MCP auth — the McpAuth / McpOrganizationAuth tags + their Live layers +// (the cloud McpAuthProvider resolves them; tests swap them), the API-key + +// JWT bearer dispatch, plus the typed auth-result discriminant the provider +// folds into the envelope's AuthOutcome. +// +// The JWT verify/classify lives in the `cloudflare:workers`-free `./jwt` leaf +// (the node-pool test imports it directly); this module reads `cloudflare: +// workers` env and depends on `./jwt`, never the other way around. +// --------------------------------------------------------------------------- + +import { env } from "cloudflare:workers"; +import { Context, Effect, Layer, Predicate } from "effect"; + +import { createCachedRemoteJWKSet } from "../auth/jwks-cache"; +import { ApiKeyService } from "../auth/api-keys"; +import { BEARER_PREFIX } from "../auth/bearer"; +import { authorizeOrganization } from "../auth/organization"; +import { UserStoreService } from "../auth/context"; +import { CoreSharedServices } from "../auth/workos"; +import { DbService } from "../db/db"; +import { bearerChallenge } from "./responses"; +import { McpJwtVerificationError, verifyWorkOSMcpAccessToken, type VerifiedToken } from "./jwt"; + +export { + McpJwtVerificationError, + verifyMcpAccessToken, + verifyWorkOSMcpAccessToken, + type VerifiedToken, +} from "./jwt"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh"; +export const RESOURCE_ORIGIN = env.MCP_RESOURCE_ORIGIN ?? "https://executor.sh"; +const WORKOS_CLIENT_ID = env.WORKOS_CLIENT_ID; + +// Module-scope cache survives across MCP requests within the same worker +// isolate. AuthKit's JWKS rotates on the order of hours/days, so a 1h TTL +// dominates the upstream cooldown without sacrificing rotation safety — +// `createCachedRemoteJWKSet` force-refreshes on key-not-found inside its +// resolver. Production telemetry showed ~222 fetches/8h with p99 1.7s on +// the previous default-cooldown setup; this collapses that to ~1 per +// isolate-hour. +const jwks = createCachedRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)); + +const MCP_PATH = "/mcp"; +export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; +export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; +export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; + +// --------------------------------------------------------------------------- +// Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`) +// --------------------------------------------------------------------------- +// +// An MCP client can pin a specific organization in the URL instead of relying on +// the token's `org_id` claim. start.ts / the test worker rewrite `/org_xxx/mcp` +// (and the org-scoped discovery doc) to the bare path the shared envelope routes +// and stash the URL-pinned org in this INTERNAL header; the provider reads it +// back. The org is re-checked against live WorkOS membership per request +// (`McpOrganizationAuth.authorize`), so the header — like the URL it came from — +// is a SELECTOR, not a trust boundary. +export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization"; + +/** The URL-pinned org for an MCP request, or `null` for the bare `/mcp`. */ +export const mcpOrganizationFromRequest = (request: Request): string | null => + request.headers.get(MCP_ORGANIZATION_HEADER); + +/** The MCP resource URL for an org (`…/org_xxx/mcp`), or the bare resource. */ +export const resourceUrlFor = (organizationId: string | null): string => + organizationId ? `${RESOURCE_ORIGIN}/${organizationId}${MCP_PATH}` : RESOURCE_URL; + +/** The protected-resource-metadata URL for an org, or the bare one. */ +export const protectedResourceMetadataUrlFor = (organizationId: string | null): string => + organizationId + ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationId}/mcp` + : PROTECTED_RESOURCE_METADATA_URL; + +type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; + +type McpAuthorizedResult = { + readonly _tag: "Authorized"; + readonly token: VerifiedToken; +}; + +type McpUnauthorizedResult = { + readonly _tag: "Unauthorized"; + readonly reason: McpUnauthorizedReason; + readonly description?: string; +}; + +export type McpAuthResult = McpAuthorizedResult | McpUnauthorizedResult; + +export const mcpAuthorized = (token: VerifiedToken): McpAuthorizedResult => ({ + _tag: "Authorized", + token, +}); + +export const mcpUnauthorized = ( + reason: McpUnauthorizedReason, + description?: string, +): McpUnauthorizedResult => ({ + _tag: "Unauthorized", + reason, + description, +}); + +/** + * Reason-sensitive RFC 9728 challenge for an Unauthorized auth result. The + * challenge points at the org-scoped resource metadata when the request pinned + * an org in the URL (`/org_xxx/mcp`), else the bare document. + */ +export const bearerChallengeFor = ( + result: McpUnauthorizedResult, + organizationId: string | null = null, +): string => + bearerChallenge( + { reason: result.reason, description: result.description }, + protectedResourceMetadataUrlFor(organizationId), + ); + +// --------------------------------------------------------------------------- +// Auth tags + Live layers +// --------------------------------------------------------------------------- + +export class McpAuth extends Context.Service< + McpAuth, + { + readonly verifyBearer: ( + request: Request, + ) => Effect.Effect; + } +>()("@executor-js/cloud/McpAuth") {} + +export class McpOrganizationAuth extends Context.Service< + McpOrganizationAuth, + { + readonly authorize: ( + accountId: string, + organizationId: string, + ) => Effect.Effect; + } +>()("@executor-js/cloud/McpOrganizationAuth") {} + +const verifyJwt = (token: string) => + verifyWorkOSMcpAccessToken(token, jwks, { + issuer: AUTHKIT_DOMAIN, + audience: WORKOS_CLIENT_ID, + }); + +const DbLive = DbService.Live; +const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); +const McpOrganizationAuthServices = Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); + +export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ + authorize: (accountId, organizationId) => + authorizeOrganization(accountId, organizationId).pipe( + Effect.map((org) => org !== null), + Effect.provide(McpOrganizationAuthServices), + ), +}); + +const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; + +export const McpAuthLive = Layer.effect( + McpAuth, + Effect.gen(function* () { + const apiKeys = yield* ApiKeyService; + + const verifyApiKey = Effect.fn("mcp.auth.verify_api_key")(function* (token: string) { + const principal = yield* apiKeys.validate(token).pipe( + Effect.catchTag("ApiKeyValidationError", (error) => + Effect.fail( + new McpJwtVerificationError({ + cause: error.cause, + reason: "system", + }), + ), + ), + ); + if (!principal) { + yield* Effect.annotateCurrentSpan({ + "mcp.auth.outcome": "invalid", + "mcp.auth.invalid_reason": "api_key", + }); + return mcpUnauthorized("invalid_token", "The API key is invalid"); + } + + yield* Effect.annotateCurrentSpan({ + "mcp.auth.outcome": "verified", + "mcp.auth.credential_type": "api_key", + "mcp.auth.has_organization": true, + }); + return mcpAuthorized({ + accountId: principal.accountId, + organizationId: principal.organizationId, + }); + }); + + const verifyJwtBearer = Effect.fn("mcp.auth.verify_jwt_bearer")(function* (token: string) { + const verified = yield* verifyJwt(token).pipe( + Effect.catchTag("McpJwtVerificationError", (error) => { + if (error.reason === "system") return Effect.fail(error); + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "mcp.auth.outcome": "invalid", + "mcp.auth.invalid_reason": error.reason, + }); + return mcpUnauthorized( + "invalid_token", + error.reason === "expired" + ? "The access token expired" + : "The access token is invalid", + ); + }); + }), + ); + if (!verified) return mcpUnauthorized("invalid_token", "The access token is invalid"); + if (Predicate.isTagged(verified, "Unauthorized")) return verified; + if (!verified.accountId) { + yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_subject" }); + return mcpUnauthorized("invalid_token", "The access token is invalid"); + } + yield* Effect.annotateCurrentSpan({ + "mcp.auth.outcome": "verified", + "mcp.auth.credential_type": "jwt", + "mcp.auth.has_organization": !!verified.organizationId, + }); + return mcpAuthorized(verified); + }); + + return { + verifyBearer: Effect.fn("mcp.auth.verify_bearer")(function* (request) { + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith(BEARER_PREFIX)) { + yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_bearer" }); + return mcpUnauthorized("missing_bearer"); + } + const token = authHeader.slice(BEARER_PREFIX.length).trim(); + if (!token) return mcpUnauthorized("invalid_token", "The bearer token is invalid"); + return yield* looksLikeJwt(token) ? verifyJwtBearer(token) : verifyApiKey(token); + }), + }; + }), +); diff --git a/apps/cloud/src/mcp/index.ts b/apps/cloud/src/mcp/index.ts new file mode 100644 index 000000000..051083bab --- /dev/null +++ b/apps/cloud/src/mcp/index.ts @@ -0,0 +1,25 @@ +// --------------------------------------------------------------------------- +// Cloud MCP — the three provider seams behind the shared host-mcp envelope, +// named to match the app composition root's `mcp: { auth, sessions, reporter }`: +// +// - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the +// two OAuth discovery docs) +// - sessions -> cloudMcpSessions (the Durable-Object session dispatch) +// - reporter -> cloudMcpReporter (forwards request-orchestration defects to +// Sentry + the dev console) +// +// These three are what `app.ts`'s `ExecutorApp.make` slots into its `mcp` +// providers; the unified app handler serves /mcp from the app layer (like +// self-host), so start.ts no longer hand-mounts MCP. The MCP-path predicate + +// test-worker envelope builder live in `./mount` (`classifyMcpPath` / +// `makeMcpWebHandler`), imported directly there. The MCP session Durable Object +// class itself stays a platform-side export (server.ts) and imports its +// siblings directly, NOT this barrel, to keep the DO bundle react-start-free. +// --------------------------------------------------------------------------- + +// `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with +// its `McpAuth`/`McpOrganizationAuth` seams provided internally), shaped as the +// `Layer` `ExecutorApp.make` expects. +export { cloudMcpAuth } from "./auth-provider"; +export { cloudMcpSessionStoreLayer as cloudMcpSessions } from "./session-store"; +export { cloudMcpReporter } from "./reporter"; diff --git a/apps/cloud/src/mcp-auth.ts b/apps/cloud/src/mcp/jwt.ts similarity index 86% rename from apps/cloud/src/mcp-auth.ts rename to apps/cloud/src/mcp/jwt.ts index ef691c11e..c7331085e 100644 --- a/apps/cloud/src/mcp-auth.ts +++ b/apps/cloud/src/mcp/jwt.ts @@ -1,3 +1,13 @@ +// --------------------------------------------------------------------------- +// MCP bearer JWT verify/classify (formerly mcp-auth.ts). +// +// Kept as its own `cloudflare:workers`-free leaf: the node-pool test +// (mcp-auth.node.test.ts) imports these verifiers at runtime, and +// `test-bearer.ts` (shared with node tests) imports `VerifiedToken` from here. +// `mcp/auth.ts` (which DOES read `cloudflare:workers` env) imports this leaf; +// the dependency points one way only. +// --------------------------------------------------------------------------- + import { Data, Effect, Result, Schema } from "effect"; import { jwtVerify, type JWTVerifyGetKey } from "jose"; import { JWKSInvalid, JWKSTimeout, JWTExpired } from "jose/errors"; diff --git a/apps/cloud/src/mcp-auth.node.test.ts b/apps/cloud/src/mcp/mcp-auth.node.test.ts similarity index 97% rename from apps/cloud/src/mcp-auth.node.test.ts rename to apps/cloud/src/mcp/mcp-auth.node.test.ts index 6e182441a..c902f1682 100644 --- a/apps/cloud/src/mcp-auth.node.test.ts +++ b/apps/cloud/src/mcp/mcp-auth.node.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; -import { - McpJwtVerificationError, - verifyMcpAccessToken, - verifyWorkOSMcpAccessToken, -} from "./mcp-auth"; +import { McpJwtVerificationError, verifyMcpAccessToken, verifyWorkOSMcpAccessToken } from "./jwt"; const issuer = "https://test-authkit.example.com"; const resource = "https://test-resource.example.com/mcp"; diff --git a/apps/cloud/src/services/mcp-oauth.node.test.ts b/apps/cloud/src/mcp/mcp-oauth.node.test.ts similarity index 91% rename from apps/cloud/src/services/mcp-oauth.node.test.ts rename to apps/cloud/src/mcp/mcp-oauth.node.test.ts index 14fd67468..13662ebc2 100644 --- a/apps/cloud/src/services/mcp-oauth.node.test.ts +++ b/apps/cloud/src/mcp/mcp-oauth.node.test.ts @@ -28,7 +28,7 @@ import { Effect, Result } from "effect"; import { ScopeId } from "@executor-js/sdk"; import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sdk/testing"; -import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; +import { asOrg, asUser, testUserOrgScopeId } from "../testing/api-harness"; // --------------------------------------------------------------------------- // Helpers @@ -109,14 +109,14 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { Effect.scoped( Effect.gen(function* () { const oauth = yield* serveOAuthTestServer(); - const orgId = `org_${crypto.randomUUID()}`; + const organizationId = `org_${crypto.randomUUID()}`; const userId = `user_${crypto.randomUUID()}`; - const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId)); + const userScope = ScopeId.make(testUserOrgScopeId(userId, organizationId)); const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; const connectionId = `mcp-oauth2-${namespace}`; const redirectUrl = "http://test.local/api/mcp/oauth/callback"; - const started = yield* asUser(userId, orgId, (client) => + const started = yield* asUser(userId, organizationId, (client) => client.oauth.start({ params: { scopeId: userScope }, payload: { @@ -137,7 +137,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { }); expect(state).toBe(started.sessionId); - const completed = yield* asUser(userId, orgId, (client) => + const completed = yield* asUser(userId, organizationId, (client) => client.oauth.complete({ params: { scopeId: userScope }, payload: { state, code }, @@ -155,11 +155,11 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { Effect.scoped( Effect.gen(function* () { const oauth = yield* serveOAuthTestServer(); - const orgId = `org_${crypto.randomUUID()}`; + const organizationId = `org_${crypto.randomUUID()}`; const userA = `user_${crypto.randomUUID()}`; const userB = `user_${crypto.randomUUID()}`; - const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId)); - const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId)); + const scopeA = ScopeId.make(testUserOrgScopeId(userA, organizationId)); + const scopeB = ScopeId.make(testUserOrgScopeId(userB, organizationId)); const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; const connectionId = `mcp-oauth2-${namespace}`; const endpoint = oauth.mcpResourceUrl; @@ -168,7 +168,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { const regsBefore = yield* countRequestsTo(oauth, "/register"); // --- User A: full OAuth round-trip, fresh DCR. --- - const startedA = yield* asUser(userA, orgId, (client) => + const startedA = yield* asUser(userA, organizationId, (client) => client.oauth.start({ params: { scopeId: scopeA }, payload: { @@ -184,7 +184,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { const redirA = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl: startedA.authorizationUrl!, }); - const completedA = yield* asUser(userA, orgId, (client) => + const completedA = yield* asUser(userA, organizationId, (client) => client.oauth.complete({ params: { scopeId: scopeA }, payload: { state: redirA.state, code: redirA.code }, @@ -194,7 +194,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { expect(yield* countRequestsTo(oauth, "/register")).toBe(regsBefore + 1); // --- User B: gets the same logical connection id in a different scope. --- - const startedB = yield* asUser(userB, orgId, (client) => + const startedB = yield* asUser(userB, organizationId, (client) => client.oauth.start({ params: { scopeId: scopeB }, payload: { @@ -210,7 +210,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { const redirB = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl: startedB.authorizationUrl!, }); - const completedB = yield* asUser(userB, orgId, (client) => + const completedB = yield* asUser(userB, organizationId, (client) => client.oauth.complete({ params: { scopeId: scopeB }, payload: { state: redirB.state, code: redirB.code }, diff --git a/apps/cloud/src/mcp/mount.ts b/apps/cloud/src/mcp/mount.ts new file mode 100644 index 000000000..c17281a39 --- /dev/null +++ b/apps/cloud/src/mcp/mount.ts @@ -0,0 +1,172 @@ +// --------------------------------------------------------------------------- +// Cloud MCP front — test-worker helpers for the shared, provider-neutral +// host-mcp serving envelope (@executor-js/host-mcp) behind cloud's two seams. +// --------------------------------------------------------------------------- +// +// PRODUCTION serves /mcp through `app.ts`'s unified `ExecutorApp.make` handler +// (the same `McpServingRoutes` envelope provided `cloudMcpAuth` + +// `cloudMcpSessions`), dispatched by start.ts alongside /api. This module is the +// TEST-WORKER counterpart: it exposes the two pieces `test-worker.ts` needs to +// build the identical envelope with swapped auth seams — +// - `makeMcpWebHandler` — bind `McpServingRoutes` to a web handler over a +// given auth provider + seam requirements + telemetry runtime, mirroring the +// self-host mount (`HttpRouter.toWebHandler`). +// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the +// two discovery docs) that start.ts's dispatch and the test worker share. +// +// Cloud's two envelope seams: +// - McpAuthProvider -> cloudMcpAuthProviderLayer (WorkOS JWT + API key + +// per-request org-liveness + the two OAuth discovery docs) +// - McpSessionStore -> cloudMcpSessionStoreLayer (Durable-Object dispatch) +// +// Streaming passthrough — the DO returns a `Response` whose body is a +// `ReadableStream` (SSE). The envelope wraps it with `HttpServerResponse.raw`, +// which passes the `Response` body through unchanged. +// --------------------------------------------------------------------------- + +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { Layer } from "effect"; + +import { McpServingRoutes } from "@executor-js/host-mcp"; + +import { + McpAuth, + McpOrganizationAuth, + MCP_ORGANIZATION_HEADER, + PROTECTED_RESOURCE_METADATA_PATH, +} from "./auth"; +import { cloudMcpReporter } from "./reporter"; +import { cloudMcpSessionStoreLayer } from "./session-store"; + +const MCP_PATH = "/mcp"; +const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; + +type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server"; + +type McpRoute = { + readonly kind: McpRouteKind; + /** Org id pinned in the URL (`/org_xxx/mcp`), or `null` for the bare path. */ + readonly organizationId: string | null; +} | null; + +// A path segment counts as an org selector only when it has the WorkOS org-id +// shape (`org_…`), so an unrelated `//mcp` still falls through to routing. +const orgIdSegment = (segment: string | undefined): string | null => + segment && segment.startsWith("org_") ? segment : null; + +// Matches a trailing MCP endpoint — `mcp` (bare) or `/mcp`. Returns the org +// id, `null` for the bare form, or `undefined` when the segments are neither. +const matchMcpSuffix = (segments: readonly string[]): string | null | undefined => { + if (segments.length === 1 && segments[0] === "mcp") return null; + if (segments.length === 2 && segments[1] === "mcp") return orgIdSegment(segments[0]) ?? undefined; + return undefined; +}; + +/** + * Returns the MCP route (kind + optional URL-pinned org) for a pathname, or + * `null` if the path isn't owned by the MCP handler. + * + * Exported so the test worker and start.ts's middleware share the exact same + * "is this an MCP path?" predicate — under the envelope `HttpRouter.toWebHandler` + * 404s unknown paths rather than returning `null`, so this gate decides whether + * to even invoke the envelope handler (null -> fall through to Start routing). + * Recognizes the bare `/mcp` + the two discovery docs AND their org-scoped + * variants (`/org_xxx/mcp`, `/.well-known/oauth-protected-resource/org_xxx/mcp`); + * only `org_…`-shaped segments are claimed. `prepareMcpOrgScope` then rewrites an + * org-scoped path to the bare path the shared envelope actually routes. + */ +export const classifyMcpPath = (pathname: string): McpRoute => { + if (pathname === AUTHORIZATION_SERVER_METADATA_PATH) { + return { kind: "oauth-authorization-server", organizationId: null }; + } + const segments = pathname.split("/").filter((segment) => segment.length > 0); + + // Protected-resource metadata: `${prefix}/mcp` or `${prefix}//mcp`. The + // org sits after the well-known prefix (RFC 9728), not at the path root. + const prmPrefix = "/.well-known/oauth-protected-resource"; + if (pathname.startsWith(`${prmPrefix}/`)) { + const organizationId = matchMcpSuffix(segments.slice(2)); + return organizationId === undefined + ? null + : { kind: "oauth-protected-resource", organizationId }; + } + + // MCP transport: `/mcp` or `//mcp`. + const organizationId = matchMcpSuffix(segments); + return organizationId === undefined ? null : { kind: "mcp", organizationId }; +}; + +const bareMcpPath = (kind: McpRouteKind): string => + kind === "mcp" + ? MCP_PATH + : kind === "oauth-protected-resource" + ? PROTECTED_RESOURCE_METADATA_PATH + : AUTHORIZATION_SERVER_METADATA_PATH; + +/** + * Normalize an org-scoped MCP request for the shared envelope, which routes ONLY + * the bare `/mcp` + bare discovery paths. Rewrites `/org_xxx/mcp` (and the + * org-scoped discovery doc) to its bare path and carries the URL-pinned org in + * the internal `MCP_ORGANIZATION_HEADER` the cloud provider reads. A bare path is + * left untouched, except any client-supplied org header is stripped — the org may + * come ONLY from the URL (membership is still re-checked per request, so this is + * a selector, not a trust boundary). Shared by start.ts (production) and the test + * worker so both classify + rewrite identically; a no-op for non-MCP paths. + */ +export const prepareMcpOrgScope = (request: Request): Request => { + const url = new URL(request.url); + const route = classifyMcpPath(url.pathname); + if (route === null) return request; + const bare = bareMcpPath(route.kind); + if (url.pathname === bare && !request.headers.has(MCP_ORGANIZATION_HEADER)) return request; + url.pathname = bare; + const rewritten = new Request(url, request); + if (route.organizationId) rewritten.headers.set(MCP_ORGANIZATION_HEADER, route.organizationId); + else rewritten.headers.delete(MCP_ORGANIZATION_HEADER); + return rewritten; +}; + +/** + * Build the envelope web handler from the shared `McpServingRoutes` Layer, + * provided cloud's two seams. Mirrors the self-host mount (apps/host-selfhost + * api.ts): `HttpRouter.provideRequest` clears the route handlers' per-request + * seam requirements, the build-time `Layer.provide(McpAuthProviderLive)` + * satisfies the `HttpRouter.use` callback's read of `discoveryRoutes`, and + * `HttpServer.layerServices` supplies the platform services for the web + * handler binding. + * + * `seamsRequirements` resolves the McpAuth + McpOrganizationAuth tags the + * provider reads; `runtime` (the WebSdk telemetry layer) is provided to the + * WHOLE router so every route-handler span lands on cloud's tracer — the same + * tracer the old `mcpApp` was provided. + * + * Exported so the test worker can build the same handler with test seam Layers. + */ +export const makeMcpWebHandler = (options: { + readonly authProvider: Layer.Layer< + import("@executor-js/host-mcp").McpAuthProvider, + never, + McpAuth | McpOrganizationAuth + >; + readonly seamsRequirements: Layer.Layer; + readonly runtime: Layer.Layer; +}): ((request: Request) => Promise) => { + const McpAuthProviderLive = options.authProvider.pipe(Layer.provide(options.seamsRequirements)); + const McpSeams = Layer.mergeAll(McpAuthProviderLive, cloudMcpSessionStoreLayer, cloudMcpReporter); + const McpRouteLive = McpServingRoutes.pipe( + HttpRouter.provideRequest(McpSeams), + Layer.provide(McpAuthProviderLive), + ); + return HttpRouter.toWebHandler( + McpRouteLive.pipe( + Layer.provideMerge(Layer.mergeAll(options.runtime, HttpServer.layerServices)), + ), + ).handler; +}; + +// Production no longer mounts /mcp here — `app.ts`'s unified `ExecutorApp.make` +// handler serves it (the same `McpServingRoutes` envelope + cloud seams as +// `cloudMcpAuth`/`cloudMcpSessions`), dispatched by start.ts alongside /api. +// `classifyMcpPath` + `makeMcpWebHandler` remain because the workerd/miniflare +// test worker (`test-worker.ts`) builds the same envelope with swapped auth +// seams and classifies MCP paths with the identical predicate. diff --git a/apps/cloud/src/mcp/oauth-metadata.ts b/apps/cloud/src/mcp/oauth-metadata.ts new file mode 100644 index 000000000..307c4dea1 --- /dev/null +++ b/apps/cloud/src/mcp/oauth-metadata.ts @@ -0,0 +1,35 @@ +// --------------------------------------------------------------------------- +// OAuth metadata endpoints — returned as web `Response`s for the envelope's +// discovery routes. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { AUTHKIT_DOMAIN, resourceUrlFor } from "./auth"; +import { CORS_ALLOW_ORIGIN } from "./responses"; + +const jsonWebResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...CORS_ALLOW_ORIGIN }, + }); + +// The `resource` reflects the URL-pinned org (`…/org_xxx/mcp`) when present, so a +// client that discovered metadata via the org-scoped well-known doc gets back the +// matching org-scoped resource id; the bare path yields the bare resource. +export const protectedResourceMetadataResponse = (organizationId: string | null = null): Response => + jsonWebResponse({ + resource: resourceUrlFor(organizationId), + authorization_servers: [AUTHKIT_DOMAIN], + bearer_methods_supported: ["header"], + scopes_supported: [], + }); + +export const authorizationServerMetadataResponse: Effect.Effect = Effect.tryPromise({ + try: async () => { + const res = await fetch(`${AUTHKIT_DOMAIN}/.well-known/oauth-authorization-server`); + if (!res.ok) return jsonWebResponse({ error: "upstream_error" }, 502); + return jsonWebResponse(await res.json()); + }, + catch: () => undefined, +}).pipe(Effect.catchCause(() => Effect.succeed(jsonWebResponse({ error: "upstream_error" }, 502)))); diff --git a/apps/cloud/src/mcp/reporter.ts b/apps/cloud/src/mcp/reporter.ts new file mode 100644 index 000000000..73d5aeca7 --- /dev/null +++ b/apps/cloud/src/mcp/reporter.ts @@ -0,0 +1,24 @@ +// --------------------------------------------------------------------------- +// Cloud MCP error reporter seam — `cloudMcpReporter`. +// +// Forwards a request-orchestration defect the shared host-mcp envelope is about +// to render as a JSON-RPC 500 to Sentry (`captureCause`) and the dev console, +// preserving the OLD `mcpApp`'s top-level +// `console.error('[mcp] request failed', …)` + `captureCause` behavior that the +// shared envelope would otherwise swallow (it returns a `Response`). +// --------------------------------------------------------------------------- + +import { Cause, Effect, Layer } from "effect"; + +import { McpErrorReporter } from "@executor-js/host-mcp"; + +import { captureCause } from "../observability"; + +export const cloudMcpReporter: Layer.Layer = Layer.succeed(McpErrorReporter)({ + report: (cause) => + Effect.sync(() => { + // oxlint-disable-next-line no-console -- boundary: preserve the old mcpApp top-level request-failure log + console.error("[mcp] request failed:", Cause.pretty(cause)); + captureCause(cause); + }), +}); diff --git a/apps/cloud/src/mcp/responses.ts b/apps/cloud/src/mcp/responses.ts index d713cf765..8be019e30 100644 --- a/apps/cloud/src/mcp/responses.ts +++ b/apps/cloud/src/mcp/responses.ts @@ -1,7 +1,6 @@ import { HttpServerResponse } from "effect/unstable/http"; -import { Effect } from "effect"; -import type { McpJwtVerificationError } from "../mcp-auth"; +import { jsonRpcErrorBody } from "@executor-js/host-mcp"; export const CORS_ALLOW_ORIGIN = { "access-control-allow-origin": "*" } as const; @@ -13,7 +12,7 @@ type UnauthorizedAuth = { const quoteAuthParam = (value: string) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; -const bearerChallenge = (auth: UnauthorizedAuth, protectedResourceMetadataUrl: string) => { +export const bearerChallenge = (auth: UnauthorizedAuth, protectedResourceMetadataUrl: string) => { const params = auth.reason === "missing_bearer" ? [`resource_metadata=${quoteAuthParam(protectedResourceMetadataUrl)}`] @@ -28,20 +27,14 @@ const bearerChallenge = (auth: UnauthorizedAuth, protectedResourceMetadataUrl: s return `Bearer ${params.join(", ")}`; }; -export const jsonResponse = (body: unknown, status = 200) => - HttpServerResponse.jsonUnsafe(body, { status, headers: CORS_ALLOW_ORIGIN }); - -export const jsonRpcError = (status: number, code: number, message: string) => - HttpServerResponse.jsonUnsafe( - { jsonrpc: "2.0", error: { code, message }, id: null }, - { status, headers: CORS_ALLOW_ORIGIN }, - ); - +/** + * The cloud edge's JSON-RPC error `Response` (CORS-on — it crosses the browser + * boundary). Delegates to the canonical `jsonRpcErrorBody` renderer; the body + * is `{jsonrpc:"2.0",error:{code,message},id:null}` with `content-type` + + * `access-control-allow-origin: *`, byte-identical to the prior local copy. + */ export const jsonRpcWebResponse = (status: number, code: number, message: string) => - new Response(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }), { - status, - headers: { ...CORS_ALLOW_ORIGIN, "content-type": "application/json" }, - }); + jsonRpcErrorBody(status, code, message); export const unauthorized = (auth: UnauthorizedAuth, protectedResourceMetadataUrl: string) => HttpServerResponse.jsonUnsafe( @@ -54,13 +47,3 @@ export const unauthorized = (auth: UnauthorizedAuth, protectedResourceMetadataUr }, }, ); - -export const authTemporarilyUnavailable = (error: McpJwtVerificationError) => - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "mcp.auth.outcome": "system_error", - "mcp.auth.system_error.reason": error.reason, - "mcp.auth.system_error.message": String(error.cause).slice(0, 500), - }); - return jsonRpcError(503, -32001, "Authentication temporarily unavailable - please retry"); - }); diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts new file mode 100644 index 000000000..de2ef2a58 --- /dev/null +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -0,0 +1,243 @@ +// --------------------------------------------------------------------------- +// Cloud MCP Session Durable Object — the cloud binding of the shared +// `McpSessionDOBase` (@executor-js/cloudflare). All session lifecycle (cold +// restore, the inactivity alarm, owner validation, transport upgrade, the +// browser-approval store, the per-request span bridge) lives in the base; cloud +// supplies ONLY its injected dependencies: +// - openSessionDb → a long-lived postgres.js handle +// - resolveSessionMeta → WorkOS/UserStore organization resolution +// - buildMcpServer → the cloud execution stack + MCP tool server +// - withTelemetry → the WebSdk tracer + W3C parent-span stitching +// - captureCause → Sentry error capture +// host-cloudflare binds the same base to D1 instead; the two stay byte-identical +// except for these seams. +// --------------------------------------------------------------------------- + +import { env } from "cloudflare:workers"; +import { createTraceState } from "@opentelemetry/api"; +import { Data, Effect, Layer } from "effect"; +import type { Cause } from "effect"; +import * as OtelTracer from "@effect/opentelemetry/Tracer"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres, { type Sql } from "postgres"; + +import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildExecuteDescription } from "@executor-js/execution"; +import { + McpSessionDOBase, + type BuiltMcpServer, + type IncomingTraceHeaders, + type McpSessionInit, + type SessionMeta, +} from "@executor-js/cloudflare/mcp/durable-object"; + +// The DO only needs the neutral boot-scoped service (WorkOSClient). It never +// bills, so it does NOT depend on any billing service — `CloudExecutionStackLayer` +// here is the no-op-decorator (Autumn-free) stack. It imports the focused +// `CoreSharedServices` root (beside `WorkOSClient`), NOT `../api/layers`, so the +// DO bundle stays small and free of the whole HTTP API assembly. (This used to +// require a dedicated `core-shared-services.ts` leaf to keep `auth/handlers.ts` → +// `@tanstack/react-start` out of the DO bundle; that coupling is gone now that +// `handlers.ts` queues cookies through `SessionAuthLive` instead.) +import { CoreSharedServices } from "../auth/workos"; +import { UserStoreService } from "../auth/context"; +import { resolveOrganization } from "../auth/organization"; +import { + DbService, + combinedSchema, + resolveConnectionString, + type DrizzleDb, + type DbServiceShape, +} from "../db/db"; +import { CloudExecutionStackLayer, makeExecutionStack } from "../engine/execution-stack"; +import { DoTelemetryLive } from "../observability/telemetry"; +import { captureCause as reportCause } from "../observability"; + +// Re-export the shared types so existing cloud importers +// (`auth/handlers.ts`, etc.) keep their `../mcp/session-durable-object` path. +export type { + McpApprovalOwner, + McpSessionApprovalResult, + McpSessionResumeApprovalResult, + McpSessionInit, + IncomingTraceHeaders, +} from "@executor-js/cloudflare/mcp/durable-object"; + +// --------------------------------------------------------------------------- +// Cloud DB handle — one postgres.js client per session runtime +// --------------------------------------------------------------------------- + +const LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS = 5; +const LONG_LIVED_DB_MAX_LIFETIME_SECONDS = 120; + +type CloudSessionDbHandle = DbServiceShape & { + readonly sql: Sql; + readonly end: () => Promise; +}; + +class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{ + readonly organizationId: string; +}> {} + +// W3C propagation across the worker→DO boundary. The worker injects its +// `traceparent` and forwards incoming `tracestate` / `baggage`; we parse the +// context and use `OtelTracer.withSpanContext` to stitch the DO's root span +// under the worker span so the entire logical request lives in one trace. +const TRACEPARENT_PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/; + +type IncomingSpanContext = { + readonly traceId: string; + readonly spanId: string; + readonly traceFlags: number; + readonly traceState?: ReturnType; +}; + +const parseTraceparent = ( + traceparent: string | null | undefined, + tracestate: string | null | undefined, +): IncomingSpanContext | null => { + if (!traceparent) return null; + const match = TRACEPARENT_PATTERN.exec(traceparent); + if (!match) return null; + return { + traceId: match[2]!, + spanId: match[3]!, + traceFlags: parseInt(match[4]!, 16), + ...(tracestate ? { traceState: createTraceState(tracestate) } : {}), + }; +}; + +/** + * The DO keeps one postgres.js client for the MCP session runtime. postgres.js + * closes idle sockets quickly, while the runtime object stays alive so the MCP + * server can preserve session-local protocol state across requests. + */ +const makeDbHandle = (options: { + readonly idleTimeout: number; + readonly maxLifetime: number; +}): CloudSessionDbHandle => { + const sql = postgres(resolveConnectionString(), { + max: 1, + idle_timeout: options.idleTimeout, + max_lifetime: options.maxLifetime, + connect_timeout: 10, + fetch_types: false, + prepare: true, + onnotice: () => undefined, + }); + return { + sql, + db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb, + // oxlint-disable-next-line executor/no-promise-catch -- boundary: postgres.js close is best-effort during DO/runtime cleanup + end: () => sql.end({ timeout: 0 }).catch(() => undefined), + }; +}; + +const makeEphemeralDb = (): CloudSessionDbHandle => + makeDbHandle({ idleTimeout: 0, maxLifetime: 60 }); + +// The org-resolution + session-runtime services. They DON'T re-provide +// `DoTelemetryLive` — that would install a second WebSdk tracer in the nested +// Effect scope, disconnecting every child span from the outer DO-method trace. +// Tracer comes from the outermost `withTelemetry` at the DO method boundary. +const makeSessionServices = (dbHandle: CloudSessionDbHandle) => { + const DbLive = Layer.succeed(DbService)({ sql: dbHandle.sql, db: dbHandle.db }); + const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); + return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); +}; + +// --------------------------------------------------------------------------- +// Durable Object +// --------------------------------------------------------------------------- + +export class McpSessionDO extends McpSessionDOBase { + protected override openSessionDb(): CloudSessionDbHandle { + return makeDbHandle({ + idleTimeout: LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS, + maxLifetime: LONG_LIVED_DB_MAX_LIFETIME_SECONDS, + }); + } + + protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect { + const dbHandle = makeEphemeralDb(); + return Effect.gen(function* () { + const org = yield* resolveOrganization(token.organizationId); + if (!org) { + return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); + } + return { + organizationId: org.id, + organizationName: org.name, + userId: token.userId, + elicitationMode: token.elicitationMode, + } satisfies SessionMeta; + }).pipe( + Effect.withSpan("McpSessionDO.resolveSessionMeta"), + Effect.provide(makeSessionServices(dbHandle)), + Effect.ensuring(Effect.promise(() => dbHandle.end())), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer + Effect.orDie, + ); + } + + protected override buildMcpServer( + sessionMeta: SessionMeta, + dbHandle: CloudSessionDbHandle, + ): Effect.Effect { + const self = this; + return Effect.gen(function* () { + const { executor, engine } = yield* makeExecutionStack( + sessionMeta.userId, + sessionMeta.organizationId, + sessionMeta.organizationName, + ).pipe( + Effect.provide(CloudExecutionStackLayer), + Effect.withSpan("McpSessionDO.makeExecutionStack"), + ); + // Build the description here so the postgres query it runs + // (`executor.sources.list`) lands as a child of `McpSessionDO.createRuntime`. + // host-mcp would otherwise call `Effect.runPromise(engine.getDescription)` + // at its async MCP-SDK boundary and orphan the sub-span. + const description = yield* buildExecuteDescription(executor); + const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; + const mcpServer = yield* createExecutorMcpServer({ + engine, + description, + parentSpan: () => self.currentParentSpan(), + debug: env.EXECUTOR_MCP_DEBUG === "true", + browserApprovalStore: self.browserApprovalStore, + elicitationMode: + sessionElicitationMode === "browser" + ? { + mode: "browser" as const, + approvalUrl: (executionId) => { + const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"; + const url = new URL(`/resume/${encodeURIComponent(executionId)}`, origin); + url.searchParams.set("mcp_session_id", self.sessionId); + return url.toString(); + }, + } + : { mode: sessionElicitationMode }, + }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer")); + return { mcpServer, engine } satisfies BuiltMcpServer; + }).pipe( + Effect.withSpan("McpSessionDO.buildMcpServer"), + Effect.provide(makeSessionServices(dbHandle)), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect + Effect.orDie, + ); + } + + protected override withTelemetry( + effect: Effect.Effect, + incoming?: IncomingTraceHeaders, + ): Effect.Effect { + const parsed = parseTraceparent(incoming?.traceparent, incoming?.tracestate); + const traced = parsed ? OtelTracer.withSpanContext(effect, parsed) : effect; + return traced.pipe(Effect.provide(DoTelemetryLive)); + } + + protected override captureCause(cause: Cause.Cause): void { + reportCause(cause); + } +} diff --git a/apps/cloud/src/mcp/session-store.ts b/apps/cloud/src/mcp/session-store.ts new file mode 100644 index 000000000..d3c4e33c5 --- /dev/null +++ b/apps/cloud/src/mcp/session-store.ts @@ -0,0 +1,35 @@ +// --------------------------------------------------------------------------- +// Cloud McpSessionStore — the shared Durable-Object dispatcher +// (@executor-js/cloudflare) over cloud's `env.MCP_SESSION` namespace. Cloud +// supplies only the stub accessors + the Sentry capture for internal errors; +// all dispatch/identity/trace/peek logic is in the shared package, identical to +// host-cloudflare. +// --------------------------------------------------------------------------- + +import * as Sentry from "@sentry/cloudflare"; +import { env } from "cloudflare:workers"; +import { Data } from "effect"; + +import { + makeDurableObjectMcpSessionStore, + type McpSessionDOStub, +} from "@executor-js/cloudflare/mcp/session-store"; + +// Cloud's Sentry capture for a JSON-RPC internal (-32603) error the response +// peeker surfaces — injected into the shared store. +class McpInternalJsonRpcError extends Data.TaggedError("McpInternalJsonRpcError")<{ + readonly message: string; +}> {} + +// The DO RPC stub structurally satisfies `McpSessionDOStub` (init/handleRequest/ +// clearSession), but `@cloudflare/workers-types` types it as a generic +// `DurableObjectStub`. Narrow at this one boundary via an `unknown` hop — a +// single cast, so no double-cast through the worker-types stub type. +const toSessionStub = (stub: unknown): McpSessionDOStub => stub as McpSessionDOStub; + +export const cloudMcpSessionStoreLayer = makeDurableObjectMcpSessionStore({ + getStub: (sessionId) => + toSessionStub(env.MCP_SESSION.get(env.MCP_SESSION.idFromString(sessionId))), + newStub: () => toSessionStub(env.MCP_SESSION.get(env.MCP_SESSION.newUniqueId())), + onInternalError: (message) => Sentry.captureException(new McpInternalJsonRpcError({ message })), +}); diff --git a/apps/cloud/src/mcp/telemetry.ts b/apps/cloud/src/mcp/telemetry.ts new file mode 100644 index 000000000..dc30b8f01 --- /dev/null +++ b/apps/cloud/src/mcp/telemetry.ts @@ -0,0 +1,221 @@ +// --------------------------------------------------------------------------- +// Client fingerprint capture +// --------------------------------------------------------------------------- +// Annotates the Effect span with everything we can learn about a connecting MCP client: the +// parsed JSON-RPC body, whitelisted request headers, CF request metadata, +// and verified-JWT claims. Lets us compare how each client (Claude Code, +// Claude.ai web, ChatGPT, custom scripts, ...) actually reports over the +// wire. Runs before dispatch so unauthorized requests still get fingerprinted. +// +// No envelope seam exists for this; the cloud McpAuthProvider invokes +// `annotateMcpRequest` inside its `authenticate` so telemetry parity holds. +// --------------------------------------------------------------------------- + +import { Effect, Match, Option, Schema } from "effect"; + +import { BEARER_PREFIX } from "../auth/bearer"; +import type { VerifiedToken } from "./auth"; + +type CfRequestMetadata = { + country?: string; + city?: string; + region?: string; + timezone?: string; + asn?: number; + asOrganization?: string; + tlsVersion?: string; + tlsCipher?: string; + httpProtocol?: string; + colo?: string; +}; + +const requestWithCf = (request: Request): Request & { cf?: CfRequestMetadata } => + request as Request & { cf?: CfRequestMetadata }; + +const getCfMeta = (request: Request): CfRequestMetadata => requestWithCf(request).cf ?? {}; + +const HEADERS_TO_DUMP = [ + "accept", + "accept-encoding", + "accept-language", + "cache-control", + "content-type", + "mcp-protocol-version", + "origin", + "referer", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "user-agent", + "x-client-name", + "x-client-version", + "x-requested-with", +] as const; + +const dumpHeaders = (request: Request): Record => { + const out: Record = {}; + for (const name of HEADERS_TO_DUMP) { + const value = request.headers.get(name); + if (value !== null) out[`mcp.http.header.${name}`] = value; + } + const authHeader = request.headers.get("authorization"); + if (authHeader) { + out["mcp.http.header.authorization.scheme"] = authHeader.split(" ", 1)[0] ?? ""; + out["mcp.http.header.authorization.length"] = String(authHeader.length); + } + // Record the full header name list too — surfaces anything unexpected + // without us having to enumerate every possibility up front. + out["mcp.http.header.names"] = Array.from(request.headers.keys()).sort().join(","); + return out; +}; + +// JSON-RPC shapes — narrow to just the fields we fingerprint. Using Schema +// collapses the typeof-guard pile and surfaces "what does an MCP client +// actually send us" as declarative types. Unknown/malformed input decodes +// to None and contributes no span attrs. + +const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); + +const JsonRpcEnvelope = Schema.Struct({ + method: Schema.optional(Schema.String), + id: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Null])), + params: Schema.optional(UnknownRecord), + // Responses to server-initiated requests arrive as POST bodies too — + // notably elicitation replies (`result.action = "accept" | "decline" | "cancel"`). + result: Schema.optional(UnknownRecord), +}); +type JsonRpcEnvelope = typeof JsonRpcEnvelope.Type; + +const ElicitationReplyResult = Schema.Struct({ + action: Schema.optional(Schema.Literals(["accept", "decline", "cancel"])), +}); + +const InitializeParams = Schema.Struct({ + protocolVersion: Schema.optional(Schema.String), + clientInfo: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + }), + ), + capabilities: Schema.optional(UnknownRecord), +}); + +const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); +const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); + +const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( + Schema.fromJsonString(JsonRpcEnvelope), +); +const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); +const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); +const decodeUriParams = Schema.decodeUnknownOption(UriParams); +const decodeElicitationReplyResult = Schema.decodeUnknownOption(ElicitationReplyResult); + +const readJsonRpcEnvelope = (request: Request): Effect.Effect> => + Effect.tryPromise({ + try: () => request.clone().text(), + catch: () => undefined, + }).pipe( + Effect.map((text) => (text ? decodeJsonRpcEnvelopeString(text) : Option.none())), + Effect.catchCause(() => Effect.succeed(Option.none())), + Effect.withSpan("mcp.request.read_json_rpc"), + ); + +const methodAttrs = (envelope: JsonRpcEnvelope): Record => { + const params = envelope.params ?? {}; + return Match.value(envelope.method).pipe( + Match.when("initialize", () => + Option.match(decodeInitializeParams(params), { + onNone: () => ({}) as Record, + onSome: (init) => ({ + ...(init.protocolVersion && { "mcp.client.protocol_version": init.protocolVersion }), + ...(init.clientInfo?.name && { "mcp.client.name": init.clientInfo.name }), + ...(init.clientInfo?.version && { "mcp.client.version": init.clientInfo.version }), + ...(init.clientInfo?.title && { "mcp.client.title": init.clientInfo.title }), + "mcp.client.capability.keys": Object.keys(init.capabilities ?? {}) + .sort() + .join(","), + }), + }), + ), + Match.when("tools/call", () => + Option.match(decodeNamedParams(params), { + onNone: () => ({}) as Record, + onSome: ({ name }) => (name ? { "mcp.tool.name": name } : {}), + }), + ), + Match.whenOr("resources/read", "resources/subscribe", () => + Option.match(decodeUriParams(params), { + onNone: () => ({}) as Record, + onSome: ({ uri }) => (uri ? { "mcp.resource.uri": uri } : {}), + }), + ), + Match.when("prompts/get", () => + Option.match(decodeNamedParams(params), { + onNone: () => ({}) as Record, + onSome: ({ name }) => (name ? { "mcp.prompt.name": name } : {}), + }), + ), + Match.option, + Option.getOrElse(() => ({}) as Record), + ); +}; + +const replyAttrs = (envelope: JsonRpcEnvelope): Record => { + if (!envelope.result || envelope.method) return {}; + return Option.match(decodeElicitationReplyResult(envelope.result), { + onNone: () => ({}), + onSome: ({ action }) => (action ? { "mcp.elicitation.action": action } : {}), + }); +}; + +const rpcAttrs = (envelope: Option.Option): Record => + Option.match(envelope, { + onNone: () => ({}), + onSome: (e) => ({ + ...(e.method && { "mcp.rpc.method": e.method }), + ...(e.id !== undefined && e.id !== null && { "mcp.rpc.id": String(e.id) }), + ...methodAttrs(e), + ...replyAttrs(e), + }), + }); + +export const annotateMcpRequest = ( + request: Request, + opts: { token: VerifiedToken | null; parseBody: boolean }, +): Effect.Effect => + Effect.gen(function* () { + const cf = getCfMeta(request); + const baseAttrs: Record = { + "mcp.request.method": request.method, + "mcp.request.session_id_present": !!request.headers.get("mcp-session-id"), + "mcp.request.session_id": request.headers.get("mcp-session-id") ?? "", + "mcp.auth.has_bearer": (request.headers.get("authorization") ?? "").startsWith(BEARER_PREFIX), + "mcp.auth.verified": !!opts.token, + "mcp.auth.organization_id": opts.token?.organizationId ?? "", + "mcp.auth.account_id": opts.token?.accountId ?? "", + "cf.country": cf.country ?? "", + "cf.city": cf.city ?? "", + "cf.region": cf.region ?? "", + "cf.timezone": cf.timezone ?? "", + "cf.asn": cf.asn ?? 0, + "cf.as_organization": cf.asOrganization ?? "", + "cf.tls_version": cf.tlsVersion ?? "", + "cf.tls_cipher": cf.tlsCipher ?? "", + "cf.http_protocol": cf.httpProtocol ?? "", + "cf.colo": cf.colo ?? "", + ...dumpHeaders(request), + }; + + const envelope = opts.parseBody ? yield* readJsonRpcEnvelope(request) : Option.none(); + const attrs = { + ...baseAttrs, + ...rpcAttrs(envelope), + "mcp.request.parse_body": opts.parseBody, + }; + + yield* Effect.annotateCurrentSpan(attrs); + yield* Effect.annotateCurrentSpan(attrs).pipe(Effect.withSpan("mcp.request.annotate")); + }); diff --git a/apps/cloud/src/services/mcp-worker-transport.test.ts b/apps/cloud/src/mcp/worker-transport.test.ts similarity index 98% rename from apps/cloud/src/services/mcp-worker-transport.test.ts rename to apps/cloud/src/mcp/worker-transport.test.ts index 4a994172c..95730d34c 100644 --- a/apps/cloud/src/services/mcp-worker-transport.test.ts +++ b/apps/cloud/src/mcp/worker-transport.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { JsonRpcRequestIdQueue, PREVIOUS_REQUEST_TIMEOUT_MS } from "./mcp-worker-transport"; +import { + JsonRpcRequestIdQueue, + PREVIOUS_REQUEST_TIMEOUT_MS, +} from "@executor-js/cloudflare/mcp/worker-transport"; const jsonRpcRequest = (body: unknown): Request => new Request("https://example.invalid/mcp", { diff --git a/apps/cloud/src/api/error-logging.ts b/apps/cloud/src/observability/error-logging.ts similarity index 100% rename from apps/cloud/src/api/error-logging.ts rename to apps/cloud/src/observability/error-logging.ts diff --git a/apps/cloud/src/observability.ts b/apps/cloud/src/observability/index.ts similarity index 100% rename from apps/cloud/src/observability.ts rename to apps/cloud/src/observability/index.ts diff --git a/apps/cloud/src/observability.test.ts b/apps/cloud/src/observability/observability.test.ts similarity index 97% rename from apps/cloud/src/observability.test.ts rename to apps/cloud/src/observability/observability.test.ts index dffaab2a7..db3ecdefa 100644 --- a/apps/cloud/src/observability.test.ts +++ b/apps/cloud/src/observability/observability.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause } from "effect"; -import { sentryPayloadForCause } from "./observability"; +import { sentryPayloadForCause } from "./index"; // Mirrors Sentry core's `is.isError`: it picks the proper-Error path iff // `Object.prototype.toString.call(x) === "[object Error]"`. Anything that diff --git a/apps/cloud/src/services/telemetry.ts b/apps/cloud/src/observability/telemetry.ts similarity index 98% rename from apps/cloud/src/services/telemetry.ts rename to apps/cloud/src/observability/telemetry.ts index facd0efb6..8652d2160 100644 --- a/apps/cloud/src/services/telemetry.ts +++ b/apps/cloud/src/observability/telemetry.ts @@ -101,6 +101,6 @@ const makeTelemetryLive = (): Layer.Layer => ), ); -export const TelemetryLive: Layer.Layer = makeTelemetryLive(); +export const WorkerTelemetryLive: Layer.Layer = makeTelemetryLive(); export const DoTelemetryLive: Layer.Layer = makeTelemetryLive(); diff --git a/apps/cloud/src/org/api.ts b/apps/cloud/src/org/api.ts index c234bfefa..116e4f148 100644 --- a/apps/cloud/src/org/api.ts +++ b/apps/cloud/src/org/api.ts @@ -1,6 +1,14 @@ -import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { UserStoreError, WorkOSError } from "../auth/errors"; +import { WorkOSError } from "../auth/errors"; +import { OrgAuth } from "../auth/middleware"; + +// --------------------------------------------------------------------------- +// Cloud-local org API — the WorkOS domain-verification surface only. Members / +// roles / invite / org-name moved to the shared provider-neutral `/account/*` +// surface (served by the WorkOS AccountProvider). Domains stay here because they +// have no provider-neutral equivalent and are cloud-only. +// --------------------------------------------------------------------------- export class Forbidden extends Schema.TaggedErrorClass()( "Forbidden", @@ -8,70 +16,10 @@ export class Forbidden extends Schema.TaggedErrorClass()( { httpApiStatus: 403 }, ) {} -const OrgMember = Schema.Struct({ - id: Schema.String, - userId: Schema.String, - email: Schema.String, - name: Schema.NullOr(Schema.String), - avatarUrl: Schema.NullOr(Schema.String), - role: Schema.String, - status: Schema.String, - lastActiveAt: Schema.NullOr(Schema.String), - isCurrentUser: Schema.Boolean, -}); - -const OrgMemberSeats = Schema.Struct({ - used: Schema.Number, - granted: Schema.Number, - unlimited: Schema.Boolean, -}); - -const OrgMembersResponse = Schema.Struct({ - members: Schema.Array(OrgMember), - seats: OrgMemberSeats, -}); - -const OrgRole = Schema.Struct({ - slug: Schema.String, - name: Schema.String, -}); - -const OrgRolesResponse = Schema.Struct({ - roles: Schema.Array(OrgRole), -}); - -const InviteBody = Schema.Struct({ - email: Schema.String, - roleSlug: Schema.optional(Schema.String), -}); - -const InviteResponse = Schema.Struct({ - id: Schema.String, - email: Schema.String, -}); - -const MembershipParams = { membershipId: Schema.String }; - const RemoveResponse = Schema.Struct({ success: Schema.Boolean, }); -const UpdateRoleBody = Schema.Struct({ - roleSlug: Schema.String, -}); - -const UpdateRoleResponse = Schema.Struct({ - success: Schema.Boolean, -}); - -const UpdateOrgNameBody = Schema.Struct({ - name: Schema.String, -}); - -const UpdateOrgNameResponse = Schema.Struct({ - name: Schema.String, -}); - const DomainItem = Schema.Struct({ id: Schema.String, domain: Schema.String, @@ -90,43 +38,7 @@ const DomainVerificationLinkResponse = Schema.Struct({ const DomainParams = { domainId: Schema.String }; -export { OrgMember, OrgMembersResponse }; - export class OrgApi extends HttpApiGroup.make("org") - .add( - HttpApiEndpoint.get("listMembers", "/org/members", { - success: OrgMembersResponse, - error: WorkOSError, - }), - ) - .add( - HttpApiEndpoint.get("listRoles", "/org/roles", { - success: OrgRolesResponse, - error: WorkOSError, - }), - ) - .add( - HttpApiEndpoint.post("invite", "/org/invite", { - payload: InviteBody, - success: InviteResponse, - error: [WorkOSError, Forbidden], - }), - ) - .add( - HttpApiEndpoint.delete("removeMember", "/org/members/:membershipId", { - params: MembershipParams, - success: RemoveResponse, - error: [WorkOSError, Forbidden], - }), - ) - .add( - HttpApiEndpoint.patch("updateMemberRole", "/org/members/:membershipId/role", { - params: MembershipParams, - payload: UpdateRoleBody, - success: UpdateRoleResponse, - error: [WorkOSError, Forbidden], - }), - ) .add( HttpApiEndpoint.get("listDomains", "/org/domains", { success: DomainsResponse, @@ -145,11 +57,7 @@ export class OrgApi extends HttpApiGroup.make("org") success: RemoveResponse, error: [WorkOSError, Forbidden], }), - ) - .add( - HttpApiEndpoint.patch("updateOrgName", "/org/name", { - payload: UpdateOrgNameBody, - success: UpdateOrgNameResponse, - error: [WorkOSError, UserStoreError, Forbidden], - }), ) {} + +/** Org API with org-level auth — requires authenticated session with an org. */ +export const OrgHttpApi = HttpApi.make("org").add(OrgApi).middleware(OrgAuth); diff --git a/apps/cloud/src/org/compose.ts b/apps/cloud/src/org/compose.ts deleted file mode 100644 index 25979da51..000000000 --- a/apps/cloud/src/org/compose.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { HttpApi } from "effect/unstable/httpapi"; -import { OrgAuth } from "../auth/middleware"; -import { OrgApi } from "./api"; - -/** Org API with org-level auth — requires authenticated session with an org. */ -export const OrgHttpApi = HttpApi.make("org").add(OrgApi).middleware(OrgAuth); diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index 9040f7bff..da60e9183 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -1,25 +1,27 @@ import { describe, it, expect } from "@effect/vitest"; import { Data, Effect, Layer } from "effect"; -import { AuthContext } from "../auth/middleware"; -import { WorkOSAuth, type WorkOSAuthService } from "../auth/workos"; +import { AuthContext } from "@executor-js/api/server"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; import { Forbidden } from "./api"; // --------------------------------------------------------------------------- -// Stub factory — only implement what each test calls +// Domain-handler guards. The member / role / invite / org-name endpoints moved +// to the shared WorkOS `AccountProvider` (covered by +// `workos-account-service.test.ts`); this group now serves only the WorkOS +// domain-verification endpoints. These tests pin the two guards those handlers +// share — `requireAdmin` and `assertDomainInSessionOrg` — which mirror +// `org/handlers.ts`. // --------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test stub needs wide function types -type StubFn = (...args: never[]) => Effect.Effect; +type StubFn = (...args: never[]) => Effect.Effect; type StubOverrides = { - listOrgMembers?: StubFn; getUserOrgMembership?: StubFn; - getUser?: StubFn; - sendInvitation?: StubFn; - deleteOrgMembership?: StubFn; - updateOrgMembershipRole?: StubFn; - listOrgRoles?: StubFn; + getOrganizationDomain?: StubFn; + getOrganization?: StubFn; + deleteOrganizationDomain?: StubFn; }; class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{ @@ -28,8 +30,8 @@ class UnstubbedWorkOSMethod extends Data.TaggedError("UnstubbedWorkOSMethod")<{ const stubWorkOS = (overrides: StubOverrides = {}) => Layer.succeed( - WorkOSAuth, - new Proxy({} as WorkOSAuthService, { + WorkOSClient, + new Proxy({} as WorkOSClientService, { get: (_target, prop) => { if (typeof prop === "string" && prop in overrides) { return overrides[prop as keyof StubOverrides]; @@ -44,16 +46,13 @@ const stubWorkOS = (overrides: StubOverrides = {}) => }), ); -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - const adminAuth = { accountId: "user_admin", organizationId: "org_1", email: "admin@test.com", name: "Admin", avatarUrl: null, + roles: [], }; const memberAuth = { @@ -62,158 +61,51 @@ const memberAuth = { email: "member@test.com", name: "Member", avatarUrl: null, + roles: [], }; -type FakeMembership = { - id: string; - userId: string; - status: string; - role: { slug: string }; -}; -type FakeUser = { - email: string; - firstName: string | null; - lastName: string | null; - profilePictureUrl: string | null; - lastSignInAt: string | null; -}; -type FakeRole = { slug: string; name: string }; - -const fakeMemberships: FakeMembership[] = [ - { - id: "mem_admin", - userId: "user_admin", - status: "active", - role: { slug: "admin" }, - }, - { - id: "mem_member", - userId: "user_member", - status: "active", - role: { slug: "member" }, - }, -]; - -const fakeUsers: Record = { - user_admin: { - email: "admin@test.com", - firstName: "Admin", - lastName: null, - profilePictureUrl: null, - lastSignInAt: "2026-04-09T00:00:00Z", - }, - user_member: { - email: "member@test.com", - firstName: "Member", - lastName: null, - profilePictureUrl: null, - lastSignInAt: null, - }, -}; - -const fakeRoles: FakeRole[] = [ - { slug: "admin", name: "Admin" }, - { slug: "member", name: "Member" }, -]; - -// --------------------------------------------------------------------------- -// The admin guard — mirrors handlers.ts -// --------------------------------------------------------------------------- +const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => + Layer.mergeAll(Layer.succeed(AuthContext)(auth), stubWorkOS(workosOverrides)); +// Mirrors `org/handlers.ts` `requireAdmin`. const requireAdmin = Effect.gen(function* () { const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); if (!current || current.role?.slug !== "admin") { return yield* new Forbidden(); } }); -const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => - Layer.mergeAll(Layer.succeed(AuthContext)(auth), stubWorkOS(workosOverrides)); - -const withMembers: StubOverrides = { - listOrgMembers: () => Effect.succeed({ data: fakeMemberships }), -}; - const withCurrentMembership: StubOverrides = { getUserOrgMembership: (_organizationId: string, userId: string) => - Effect.succeed(fakeMemberships.find((m) => m.userId === userId) ?? null), + Effect.succeed( + userId === "user_admin" + ? { id: "mem_admin", userId, status: "active", role: { slug: "admin" } } + : { id: "mem_member", userId, status: "active", role: { slug: "member" } }, + ), }; -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("Org handlers", () => { - describe("listMembers", () => { - it.effect("returns members with isCurrentUser set correctly", () => - Effect.gen(function* () { - const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - const result = yield* workos.listOrgMembers(auth.organizationId); - const members = yield* Effect.all( - result.data.map((m: FakeMembership) => - Effect.gen(function* () { - const user = yield* workos.getUser(m.userId); - return { - id: m.id, - email: user.email, - role: m.role?.slug ?? "member", - isCurrentUser: m.userId === auth.accountId, - }; - }), - ), - ); - - expect(members).toHaveLength(2); - expect(members[0]).toMatchObject({ - email: "admin@test.com", - isCurrentUser: true, - }); - expect(members[1]).toMatchObject({ - email: "member@test.com", - isCurrentUser: false, - }); - }).pipe( - Effect.provide( - provide(adminAuth, { - ...withMembers, - getUser: (id: string) => Effect.succeed(fakeUsers[id]), - }), - ), - ), - ); - }); - - describe("listRoles", () => { - it.effect("returns available roles", () => - Effect.gen(function* () { - const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - const result = yield* workos.listOrgRoles(auth.organizationId); - const roles = result.data.map((r: FakeRole) => ({ - slug: r.slug, - name: r.name, - })); - - expect(roles).toEqual(fakeRoles); - }).pipe( - Effect.provide( - provide(adminAuth, { - listOrgRoles: () => Effect.succeed({ data: fakeRoles }), - }), - ), - ), - ); +// Mirrors `org/handlers.ts` `assertDomainInSessionOrg`. +const assertDomainInSessionOrg = (domainId: string) => + Effect.gen(function* () { + const auth = yield* AuthContext; + const workos = yield* WorkOSClient; + const domain = yield* workos + .getOrganizationDomain(domainId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (!domain || domain.organizationId !== auth.organizationId) { + return yield* new Forbidden(); + } }); +describe("Org domain handlers", () => { describe("requireAdmin", () => { - it.effect("passes for admin user", () => + it.effect("passes for an admin caller", () => requireAdmin.pipe(Effect.provide(provide(adminAuth, withCurrentMembership))), ); - it.effect("rejects non-admin with Forbidden", () => + it.effect("rejects a non-admin caller with Forbidden", () => Effect.gen(function* () { const error = yield* Effect.flip(requireAdmin); expect(error).toBeInstanceOf(Forbidden); @@ -221,103 +113,43 @@ describe("Org handlers", () => { ); }); - describe("invite (admin-gated)", () => { - it.effect("admin can invite", () => - Effect.gen(function* () { - yield* requireAdmin; - const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - const result = yield* workos.sendInvitation({ - email: "new@test.com", - organizationId: auth.organizationId, - }); - - expect(result.email).toBe("new@test.com"); - }).pipe( + describe("assertDomainInSessionOrg", () => { + it.effect("passes when the domain belongs to the session org", () => + assertDomainInSessionOrg("dom_1").pipe( Effect.provide( provide(adminAuth, { - ...withCurrentMembership, - sendInvitation: (p: { email: string }) => - Effect.succeed({ id: "inv_1", email: p.email }), + getOrganizationDomain: () => + Effect.succeed({ id: "dom_1", organizationId: "org_1", domain: "acme.test" }), }), ), ), ); - it.effect("member cannot invite", () => + it.effect("rejects a domain owned by a different org with Forbidden", () => Effect.gen(function* () { - const error = yield* Effect.flip( - Effect.gen(function* () { - yield* requireAdmin; - const workos = yield* WorkOSAuth; - yield* workos.sendInvitation({ - email: "x", - organizationId: "org_1", - }); - }), - ); + const error = yield* Effect.flip(assertDomainInSessionOrg("dom_other")); expect(error).toBeInstanceOf(Forbidden); - }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), - ); - }); - - describe("removeMember (admin-gated)", () => { - it.effect("admin can remove", () => - Effect.gen(function* () { - yield* requireAdmin; - const workos = yield* WorkOSAuth; - yield* workos.deleteOrgMembership("mem_member"); }).pipe( Effect.provide( provide(adminAuth, { - ...withCurrentMembership, - deleteOrgMembership: () => Effect.void, + getOrganizationDomain: () => + Effect.succeed({ id: "dom_other", organizationId: "org_2", domain: "evil.test" }), }), ), ), ); - it.effect("member cannot remove", () => + it.effect("rejects (Forbidden) when the domain lookup fails — never leaks existence", () => Effect.gen(function* () { - const error = yield* Effect.flip( - Effect.gen(function* () { - yield* requireAdmin; - const workos = yield* WorkOSAuth; - yield* workos.deleteOrgMembership("mem_admin"); - }), - ); + const error = yield* Effect.flip(assertDomainInSessionOrg("dom_missing")); expect(error).toBeInstanceOf(Forbidden); - }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), - ); - }); - - describe("updateMemberRole (admin-gated)", () => { - it.effect("admin can change role", () => - Effect.gen(function* () { - yield* requireAdmin; - const workos = yield* WorkOSAuth; - yield* workos.updateOrgMembershipRole("mem_member", "admin"); }).pipe( Effect.provide( provide(adminAuth, { - ...withCurrentMembership, - updateOrgMembershipRole: () => Effect.void, + getOrganizationDomain: () => Effect.fail(new UnstubbedWorkOSMethod({ method: "boom" })), }), ), ), ); - - it.effect("member cannot change role", () => - Effect.gen(function* () { - const error = yield* Effect.flip( - Effect.gen(function* () { - yield* requireAdmin; - const workos = yield* WorkOSAuth; - yield* workos.updateOrgMembershipRole("mem_admin", "member"); - }), - ); - expect(error).toBeInstanceOf(Forbidden); - }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), - ); }); }); diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index 4309b238f..a12811cec 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -1,49 +1,40 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { Cause, Effect } from "effect"; +import { Effect } from "effect"; -import { UserStoreService } from "../auth/context"; -import { AuthContext } from "../auth/middleware"; +import { AuthContext } from "@executor-js/api/server"; import { env } from "cloudflare:workers"; -import { WorkOSAuth } from "../auth/workos"; -import { AutumnService } from "../services/autumn"; -import { OrgHttpApi } from "./compose"; -import { Forbidden } from "./api"; -import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits"; +import { WorkOSClient } from "../auth/workos"; +import { AutumnService } from "../extensions/billing/service"; +import { Forbidden, OrgHttpApi } from "./api"; + +// --------------------------------------------------------------------------- +// Cloud-local org handlers — WorkOS domain-verification only. Members / roles / +// invite / org-name are served by the shared WorkOS `AccountProvider` over +// `/account/*`; this group covers the cloud-only domain endpoints behind +// `OrgAuth` (org-scoped cookie session). +// --------------------------------------------------------------------------- const requireAdmin = Effect.gen(function* () { const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); if (!currentMembership || currentMembership.role?.slug !== "admin") { return yield* new Forbidden(); } }); -// Target-ownership checks — independent of caller privilege. `requireAdmin` -// confirms the caller is an admin of their session's org; these confirm the -// resource they're about to mutate actually lives in that same org. Without -// this, an admin of org A who obtained a membership/domain id from org B -// (leak, screenshot, support context) could trigger the WorkOS SDK against -// org B's resource — the workspace API key is workspace-wide and WorkOS -// does not enforce per-org ownership on delete/update by id. Failures -// (not found OR org mismatch) both surface as Forbidden so we don't leak -// existence of ids outside the caller's org. -const assertMembershipInSessionOrg = (membershipId: string) => - Effect.gen(function* () { - const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - const membership = yield* workos - .getOrgMembership(membershipId) - .pipe(Effect.catchCause(() => Effect.succeed(null))); - if (!membership || membership.organizationId !== auth.organizationId) { - return yield* new Forbidden(); - } - }); - +// Target-ownership check — independent of caller privilege. `requireAdmin` +// confirms the caller is an admin of their session's org; this confirms the +// domain they're about to delete actually lives in that same org. Without it, +// an admin of org A who obtained a domain id from org B (leak, screenshot, +// support context) could trigger the WorkOS SDK against org B's resource — the +// workspace API key is workspace-wide and WorkOS does not enforce per-org +// ownership on delete by id. Failures (not found OR org mismatch) both surface +// as Forbidden so we don't leak existence of ids outside the caller's org. const assertDomainInSessionOrg = (domainId: string) => Effect.gen(function* () { const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const domain = yield* workos .getOrganizationDomain(domainId) .pipe(Effect.catchCause(() => Effect.succeed(null))); @@ -52,167 +43,12 @@ const assertDomainInSessionOrg = (domainId: string) => } }); -// Compute live seat usage from WorkOS truth (active+pending memberships + -// pending invitations) and look up the per-plan cap from MEMBER_LIMITS. -// Recomputed on every call — no event-counting drift. -const getMemberSeats = (organizationId: string) => - Effect.gen(function* () { - const autumn = yield* AutumnService; - const workos = yield* WorkOSAuth; - - const customer = yield* autumn.use((client) => - client.customers.getOrCreate({ customerId: organizationId }), - ); - const planId = selectActiveMemberLimitPlan(customer.subscriptions); - const limit = getMemberLimitForPlan(planId); - - const memberships = yield* workos.listOrgMembers(organizationId); - const invitations = yield* workos.listPendingInvitations(organizationId); - - return { - used: memberships.data.length + invitations.data.length, - granted: limit ?? 0, - unlimited: limit === null, - }; - }); - -const reserveMemberSlot = Effect.gen(function* () { - const auth = yield* AuthContext; - const seats = yield* getMemberSeats(auth.organizationId).pipe( - Effect.tap((s) => - Effect.logInfo("members.check").pipe( - Effect.annotateLogs({ - "org.id": auth.organizationId, - "members.used": s.used, - "members.granted": s.granted, - "members.unlimited": s.unlimited, - }), - ), - ), - Effect.catchCause((cause) => - Effect.gen(function* () { - yield* Effect.logError("members.seats lookup failed; failing closed").pipe( - Effect.annotateLogs({ "org.id": auth.organizationId, cause: Cause.pretty(cause) }), - ); - return yield* new Forbidden(); - }), - ), - ); - - if (!seats.unlimited && seats.used >= seats.granted) { - return yield* new Forbidden(); - } -}); - export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => handlers - .handle("listMembers", () => - Effect.gen(function* () { - const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - - // The list endpoint falls back to safe display defaults if the seats - // lookup errors — we never want a transient Autumn or WorkOS hiccup - // to blank the members page. The actual cap gate lives in - // `reserveMemberSlot`, which fails closed. - const seats = yield* getMemberSeats(auth.organizationId).pipe( - Effect.catchTag("AutumnError", (error) => - Effect.logError("listMembers.seats: autumn lookup failed").pipe( - Effect.annotateLogs({ "org.id": auth.organizationId, error: String(error.cause) }), - Effect.as({ used: 0, granted: 0, unlimited: false }), - ), - ), - ); - - const memberships = yield* workos.listOrgMembers(auth.organizationId); - - yield* Effect.logInfo("listMembers.seats").pipe( - Effect.annotateLogs({ - "org.id": auth.organizationId, - "members.count": memberships.data.length, - "seats.used": seats.used, - "seats.granted": seats.granted, - "seats.unlimited": seats.unlimited, - }), - ); - - const members = yield* Effect.all( - memberships.data.map((m) => - Effect.gen(function* () { - const user = yield* workos.getUser(m.userId); - return { - id: m.id, - userId: m.userId, - email: user.email, - name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, - avatarUrl: user.profilePictureUrl ?? null, - role: m.role?.slug ?? "member", - status: m.status, - lastActiveAt: user.lastSignInAt ?? null, - isCurrentUser: m.userId === auth.accountId, - }; - }), - ), - { concurrency: 5 }, - ); - - return { members, seats }; - }), - ) - .handle("listRoles", () => - Effect.gen(function* () { - const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - - const result = yield* workos.listOrgRoles(auth.organizationId); - - return { - roles: result.data.map((r) => ({ - slug: r.slug, - name: r.name, - })), - }; - }), - ) - .handle("invite", ({ payload }) => - Effect.gen(function* () { - yield* requireAdmin; - const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - - yield* reserveMemberSlot; - - const invitation = yield* workos.sendInvitation({ - email: payload.email, - organizationId: auth.organizationId, - roleSlug: payload.roleSlug, - }); - - return { id: invitation.id, email: invitation.email }; - }), - ) - .handle("removeMember", ({ params }) => - Effect.gen(function* () { - yield* requireAdmin; - yield* assertMembershipInSessionOrg(params.membershipId); - const workos = yield* WorkOSAuth; - yield* workos.deleteOrgMembership(params.membershipId); - return { success: true }; - }), - ) - .handle("updateMemberRole", ({ params, payload }) => - Effect.gen(function* () { - yield* requireAdmin; - yield* assertMembershipInSessionOrg(params.membershipId); - const workos = yield* WorkOSAuth; - yield* workos.updateOrgMembershipRole(params.membershipId, payload.roleSlug); - return { success: true }; - }), - ) .handle("listDomains", () => Effect.gen(function* () { const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const org = yield* workos.getOrganization(auth.organizationId); const domains = yield* Effect.all( @@ -253,7 +89,7 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => return yield* new Forbidden(); } - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; const { link } = yield* workos.generateDomainVerificationPortalLink( auth.organizationId, env.VITE_PUBLIC_SITE_URL ? `${env.VITE_PUBLIC_SITE_URL}/org` : "/org", @@ -265,20 +101,9 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => Effect.gen(function* () { yield* requireAdmin; yield* assertDomainInSessionOrg(params.domainId); - const workos = yield* WorkOSAuth; + const workos = yield* WorkOSClient; yield* workos.deleteOrganizationDomain(params.domainId); return { success: true }; }), - ) - .handle("updateOrgName", ({ payload }) => - Effect.gen(function* () { - yield* requireAdmin; - const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - const users = yield* UserStoreService; - const org = yield* workos.updateOrganization(auth.organizationId, payload.name); - yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name })); - return { name: org.name }; - }), ), ); diff --git a/apps/cloud/src/org/member-limits.ts b/apps/cloud/src/org/member-limits.ts deleted file mode 100644 index 8cff10069..000000000 --- a/apps/cloud/src/org/member-limits.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES } from "../services/autumn-plans"; - -const MEMBER_LIMITS: Record = { - free: 3, - "free-pay-as-you-go": 3, - team: null, - enterprise: null, -}; - -export const DEFAULT_MEMBER_LIMIT = 3; - -export type AutumnSubscriptionSummary = { - readonly planId?: string | null; - readonly status?: string | null; -}; - -export const selectActiveMemberLimitPlan = ( - subscriptions: ReadonlyArray, -): string => { - const active = - subscriptions.find((subscription) => - ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""), - ) ?? subscriptions[0]; - return active?.planId ?? "free"; -}; - -export const getMemberLimitForPlan = (planId: string): number | null => - planId in MEMBER_LIMITS ? MEMBER_LIMITS[planId] : DEFAULT_MEMBER_LIMIT; diff --git a/apps/cloud/src/api/cloud-plugins.ts b/apps/cloud/src/plugins.ts similarity index 86% rename from apps/cloud/src/api/cloud-plugins.ts rename to apps/cloud/src/plugins.ts index c4f006e0e..f3852bdca 100644 --- a/apps/cloud/src/api/cloud-plugins.ts +++ b/apps/cloud/src/plugins.ts @@ -4,14 +4,14 @@ // module-eval time without runtime credentials: the heavy per-request // dependencies (WorkOS Vault credentials, vault HTTP client) are only // consumed when the plugin's extension is actually constructed inside -// `createScopedExecutor`. Both the API composition (`protected-layers.ts`) +// `createScopedExecutor`. Both the API composition (`layers.ts`) // and the per-request middleware (`protected.ts` + the test harness) // derive their typed views — `composePluginApi(cloudPlugins)`, // `composePluginHandlerLayer(cloudPlugins)`, // `providePluginExtensions(cloudPlugins)`, `PluginExtensionServices` — from this one tuple, so adding/removing a plugin is // still a single `executor.config.ts` edit. -import executorConfig from "../../executor.config"; +import executorConfig from "../executor.config"; export const cloudPlugins = executorConfig.plugins(); export type CloudPlugins = typeof cloudPlugins; diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 154badefb..d6e95e87c 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -237,7 +237,7 @@ function AuthGate() { } return ( - + } showDialog={false}> } onHandledError={captureFrontendError}> diff --git a/apps/cloud/src/routes/api-keys.tsx b/apps/cloud/src/routes/api-keys.tsx index e12db738f..ce5afe51c 100644 --- a/apps/cloud/src/routes/api-keys.tsx +++ b/apps/cloud/src/routes/api-keys.tsx @@ -1,272 +1,8 @@ -import { useState } from "react"; -import { Exit } from "effect"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { createFileRoute } from "@tanstack/react-router"; -import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { toast } from "sonner"; -import { apiKeyWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { Button } from "@executor-js/react/components/button"; -import { CopyButton } from "@executor-js/react/components/copy-button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@executor-js/react/components/dialog"; -import { Input } from "@executor-js/react/components/input"; -import { Label } from "@executor-js/react/components/label"; -import { apiKeysAtom, createApiKey, revokeApiKey } from "../web/api-key-atoms"; +import { ApiKeysPage } from "@executor-js/react/pages/api-keys"; +// Cloud renders the SHARED API-keys page over the provider-neutral +// `/account/api-keys` surface — identical UI to self-host. export const Route = createFileRoute("/api-keys")({ component: ApiKeysPage, }); - -type ApiKeySummary = { - readonly id: string; - readonly name: string; - readonly obfuscatedValue: string; - readonly createdAt: string; - readonly lastUsedAt: string | null; -}; - -type CreatedKey = ApiKeySummary & { - readonly value: string; -}; - -const formatDate = (value: string | null): string => { - if (!value) return "Never"; - const date = new Date(value); - return Number.isNaN(date.getTime()) - ? value - : new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }).format(date); -}; - -const defaultApiKeyName = (): string => - `API key ${new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }).format(new Date())}`; - -function ApiKeysPage() { - const result = useAtomValue(apiKeysAtom); - const doCreate = useAtomSet(createApiKey, { mode: "promiseExit" }); - const doRevoke = useAtomSet(revokeApiKey, { mode: "promiseExit" }); - const [createOpen, setCreateOpen] = useState(false); - const [name, setName] = useState(""); - const [createdKey, setCreatedKey] = useState(null); - const [creating, setCreating] = useState(false); - const [revokingId, setRevokingId] = useState(null); - - const handleCreate = async () => { - const trimmed = name.trim(); - if (!trimmed) return; - setCreating(true); - const exit = await doCreate({ - payload: { name: trimmed }, - reactivityKeys: apiKeyWriteKeys, - }); - setCreating(false); - if (Exit.isSuccess(exit)) { - setCreatedKey(exit.value); - setName(""); - toast.success("API key created"); - return; - } - toast.error("Failed to create API key"); - }; - - const handleRevoke = async (key: ApiKeySummary) => { - setRevokingId(key.id); - const exit = await doRevoke({ - params: { apiKeyId: key.id }, - reactivityKeys: apiKeyWriteKeys, - }); - setRevokingId(null); - if (Exit.isSuccess(exit)) { - toast.success(`Revoked ${key.name}`); - return; - } - toast.error("Failed to revoke API key"); - }; - - const closeCreate = (open: boolean) => { - setCreateOpen(open); - if (!open) { - setName(""); - setCreatedKey(null); - setCreating(false); - } - }; - - return ( -
-
-
-
-

API keys

-

- User keys for accessing the Executor API and MCP endpoint from scripts and tools. -

-
- - Authorization: Bearer <api-key> - - -
-

- API keys work as PATs and have full access to your account. -

-
- -
- - {AsyncResult.match(result, { - onInitial: () => ( -
- Loading API keys... -
- ), - onFailure: () => ( -
- Failed to load API keys -
- ), - onSuccess: ({ value }) => - value.apiKeys.length === 0 ? ( -
-

No API keys

-

- Create a key and send it in the Authorization Bearer header. -

-
- ) : ( -
-
- Name - Created - Last used - Actions -
- {value.apiKeys.map((key: ApiKeySummary) => ( -
-
-

{key.name}

-

- {key.obfuscatedValue} -

-
-

- {formatDate(key.createdAt)} -

-

- {formatDate(key.lastUsedAt)} -

- -
- ))} -
- ), - })} -
- - - - - Create API key - - The key will act as your user in the current organization. - - - - {createdKey ? ( -
-
- -
- - -
-
-
- -
- - -
-
-

- Send this value as a Bearer token. It is only shown once. -

-
- ) : ( -
-
- - setName(event.target.value)} - placeholder="Local CLI" - maxLength={80} - autoFocus - /> -
-
- )} - - - - - - {!createdKey && ( - - )} - -
-
-
- ); -} diff --git a/apps/cloud/src/routes/org.tsx b/apps/cloud/src/routes/org.tsx index 6b7ab7b3d..43edadffb 100644 --- a/apps/cloud/src/routes/org.tsx +++ b/apps/cloud/src/routes/org.tsx @@ -1,203 +1,102 @@ -import { useReducer, useState } from "react"; -import { Cause, Exit, Match, Result } from "effect"; -import { Forbidden } from "../org/api"; import { createFileRoute, Link } from "@tanstack/react-router"; +import { Exit } from "effect"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { useCustomer } from "autumn-js/react"; import { toast } from "sonner"; -import { - orgMemberWriteKeys, - orgDomainWriteKeys, - orgInfoWriteKeys, -} from "@executor-js/react/api/reactivity-keys"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, - DialogClose, -} from "@executor-js/react/components/dialog"; +import { orgDomainWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { Button } from "@executor-js/react/components/button"; import { Badge } from "@executor-js/react/components/badge"; import { CopyButton } from "@executor-js/react/components/copy-button"; -import { Input } from "@executor-js/react/components/input"; -import { Label } from "@executor-js/react/components/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@executor-js/react/components/select"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, - DropdownMenuSeparator, } from "@executor-js/react/components/dropdown-menu"; -import { - orgMembersAtom, - orgRolesAtom, - orgDomainsAtom, - inviteMember, - removeMember, - updateMemberRole, - getDomainVerificationLink, - deleteDomain, - updateOrgName, -} from "../web/org-atoms"; -import { useAuth } from "../web/auth"; +import { OrgPage as SharedOrgPage } from "@executor-js/react/pages/org"; +import { orgMembersAtom } from "@executor-js/react/api/account-atoms"; +import { orgDomainsAtom, getDomainVerificationLink, deleteDomain } from "../web/org-atoms"; + +// --------------------------------------------------------------------------- +// Cloud organization page. The members / roles / invite / org-name surface is +// the SHARED `@executor-js/react` OrgPage over the provider-neutral +// `/account/*` atoms — identical to self-host. Cloud composes its WorkOS-only +// extras AROUND that page: +// - a seat/billing banner (Autumn member-limit upsell) +// - the WorkOS domain-verification section (over the surviving cloud-local +// `/org/domains` endpoints) +// These are cloud additions, not a fork of the shared page. +// --------------------------------------------------------------------------- export const Route = createFileRoute("/org")({ component: OrgPage, }); -type InviteState = { - email: string; - roleSlug: string; - status: "idle" | "sending" | "error"; - failure: Cause.Cause | null; -}; - -const initialInviteState: InviteState = { - email: "", - roleSlug: "member", - status: "idle", - failure: null, +type DomainData = { + id: string; + domain: string; + state: string; + verificationToken?: string; + verificationPrefix?: string; }; -type InviteAction = - | { type: "setEmail"; email: string } - | { type: "setRole"; roleSlug: string } - | { type: "send" } - | { type: "error"; cause: Cause.Cause } - | { type: "reset" }; - -function inviteReducer(state: InviteState, action: InviteAction): InviteState { - return Match.value(action).pipe( - Match.discriminator("type")("setEmail", (a) => ({ ...state, email: a.email })), - Match.discriminator("type")("setRole", (a) => ({ ...state, roleSlug: a.roleSlug })), - Match.discriminator("type")("send", () => ({ - ...state, - status: "sending" as const, - failure: null, - })), - Match.discriminator("type")("error", (a) => ({ - ...state, - status: "error" as const, - failure: a.cause, - })), - Match.discriminator("type")("reset", () => initialInviteState), - Match.exhaustive, +function OrgPage() { + return ( +
+
+ + +
+ {/* Shared members / roles / invite / org-name surface. */} + +
); } -function formatLastActive(lastActiveAt: string | null): string { - if (!lastActiveAt) return "\u2014"; - const date = new Date(lastActiveAt); - const diffMs = Date.now() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 30) return `${diffDays}d ago`; - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +// Autumn-backed member-seat banner. The hard cap is enforced server-side in +// the `/account/inviteMember` handler (AccountForbidden), so this is purely an +// affordance: surface the upgrade CTA once the org is at/over its seat limit. +function MemberLimitBanner() { + const membersResult = useAtomValue(orgMembersAtom); + const seats = AsyncResult.match(membersResult, { + onInitial: () => null, + onFailure: () => null, + onSuccess: ({ value }) => value.seats ?? null, + }); + const atLimit = seats ? !seats.unlimited && seats.used >= seats.granted : false; + if (!atLimit) return null; + return ( +
+

+ You've reached your member limit. Upgrade to Team to invite more. +

+ + + +
+ ); } -function OrgPage() { - const auth = useAuth(); - const orgName = - auth.status === "authenticated" ? (auth.organization?.name ?? "Organization") : "Organization"; - const membersResult = useAtomValue(orgMembersAtom); - const rolesResult = useAtomValue(orgRolesAtom); +function DomainsSection() { const domainsResult = useAtomValue(orgDomainsAtom); - const doRemove = useAtomSet(removeMember, { mode: "promiseExit" }); - const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" }); const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" }); const doGetVerificationLink = useAtomSet(getDomainVerificationLink, { mode: "promiseExit" }); - const doUpdateOrgName = useAtomSet(updateOrgName, { mode: "promiseExit" }); const { check, isLoading: customerLoading } = useCustomer(); const canUseDomains = customerLoading ? false : check({ featureId: "domain-verification" }).allowed; - const seats = AsyncResult.match(membersResult, { - onInitial: () => null, - onFailure: () => null, - onSuccess: ({ value }) => value.seats ?? null, - }); - const canInviteMember = !seats ? false : seats.unlimited || seats.used < seats.granted; - const [inviteOpen, setInviteOpen] = useState(false); - const [editName, setEditName] = useState(orgName); - const [savingName, setSavingName] = useState(false); - const [search, setSearch] = useState(""); - - const roles = AsyncResult.match(rolesResult, { - onInitial: () => [] as readonly { slug: string; name: string }[], - onFailure: () => [] as readonly { slug: string; name: string }[], - onSuccess: ({ value }) => value.roles, - }); - - const handleRemove = async (membershipId: string, name: string) => { - const exit = await doRemove({ params: { membershipId }, reactivityKeys: orgMemberWriteKeys }); - if (Exit.isSuccess(exit)) { - toast.success(`Removed ${name}`); - } else { - toast.error("Failed to remove member"); - } - }; - - const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => { - const exit = await doUpdateRole({ - params: { membershipId }, - payload: { roleSlug }, - reactivityKeys: orgMemberWriteKeys, - }); - if (Exit.isSuccess(exit)) { - toast.success(`Role changed to ${roleName}`); - } else { - toast.error("Failed to change role"); - } - }; - - const handleSaveName = async () => { - const trimmed = editName.trim(); - if (!trimmed || trimmed === orgName) { - setEditName(orgName); - return; - } - setSavingName(true); - const exit = await doUpdateOrgName({ - payload: { name: trimmed }, - reactivityKeys: orgInfoWriteKeys, - }); - if (Exit.isSuccess(exit)) { - toast.success("Organization name updated"); - } else { - toast.error("Failed to update organization name"); - setEditName(orgName); - } - setSavingName(false); - }; const handleDeleteDomain = async (domainId: string, domain: string) => { const exit = await doDeleteDomain({ params: { domainId }, reactivityKeys: orgDomainWriteKeys, }); - if (Exit.isSuccess(exit)) { - toast.success(`Removed ${domain}`); - } else { - toast.error("Failed to remove domain"); - } + toast[Exit.isSuccess(exit) ? "success" : "error"]( + Exit.isSuccess(exit) ? `Removed ${domain}` : "Failed to remove domain", + ); }; const handleAddDomain = async () => { @@ -210,326 +109,72 @@ function OrgPage() { }; return ( -
-
- {/* Header */} -
-

Organization

+
+
+
+

Domains

+

+ Verify a domain to let anyone with a matching email join automatically. +

+ +
- {/* Settings */} -
-
-
- - setEditName((e.target as HTMLInputElement).value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleSaveName(); - }} - className="mt-1.5 h-9 text-sm" - /> -
- {editName.trim() !== orgName && editName.trim() !== "" && ( - - )} -
-
- - {/* Domains */} -
-
-
-

Domains

-

- Verify a domain to let anyone with a matching email join automatically. -

-
- -
- - {!canUseDomains && ( -
-

- Join by domain is available on the Team plan. -

- - - -
- )} - - {AsyncResult.match(domainsResult, { - onInitial: () => ( -
- {[1, 2].map((i) => ( -
- ))} -
- ), - onFailure: () => ( -
-

Failed to load domains

-
- ), - onSuccess: ({ value }) => { - if (value.domains.length === 0) { - if (!canUseDomains) return null; - return ( -

- No domains yet. Add your company domain so members can join without an invite. -

- ); - } - - return ( -
- {value.domains.map((d: DomainData) => ( - handleDeleteDomain(d.id, d.domain)} - /> - ))} -
- ); - }, - })} -
+ +
+ )} - {/* Members */} -
-
-
-

Members

-

- Free organizations can include up to 3 members. + {AsyncResult.match(domainsResult, { + onInitial: () => ( +

+ {[1, 2].map((i) => ( +
+ ))} +
+ ), + onFailure: () => ( +
+

Failed to load domains

+
+ ), + onSuccess: ({ value }) => { + if (value.domains.length === 0) { + if (!canUseDomains) return null; + return ( +

+ No domains yet. Add your company domain so members can join without an invite.

+ ); + } + + return ( +
+ {value.domains.map((d: DomainData) => ( + handleDeleteDomain(d.id, d.domain)} + /> + ))}
- {canInviteMember ? ( - - ) : ( - - - - )} -
- setSearch((e.target as HTMLInputElement).value)} - className="mb-3 h-9 text-sm" - /> - - {AsyncResult.match(membersResult, { - onInitial: () => ( -
- {[1, 2, 3].map((i) => ( -
- ))} -
- ), - onFailure: () => ( -
-

Failed to load members

-
- ), - onSuccess: ({ value }) => { - const members = value.members; - const filtered = search - ? members.filter( - (m: MemberData) => - m.email.toLowerCase().includes(search.toLowerCase()) || - (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), - ) - : members; - - if (filtered.length === 0) { - return ( -

- {search ? "No matching members" : "No members yet"} -

- ); - } - - return ( -
- {filtered.map((member: MemberData) => ( -
- {/* Avatar */} - {member.avatarUrl ? ( - - ) : ( -
- {member.name - ? member.name - .split(" ") - .map((n: string) => n[0]) - .join("") - .slice(0, 2) - .toUpperCase() - : member.email[0]!.toUpperCase()} -
- )} - - {/* Name + email */} -
-
-

- {member.name ?? member.email} -

- {member.isCurrentUser && ( - You - )} - {member.status === "pending" && ( - - Invited - - )} -
- {member.name && ( -

- {member.email} -

- )} -
- - {/* Role */} -

- {member.role} -

- - {/* Last active */} -

- {formatLastActive(member.lastActiveAt)} -

- - {/* Actions */} - {!member.isCurrentUser ? ( - - - - - - {roles.length > 0 && ( - <> - - - Change role - - - {roles.map((role: RoleData) => ( - - handleChangeRole(member.id, role.slug, role.name) - } - > - {role.name} - {role.slug === member.role && ( - - - - - - )} - - ))} - - - - - )} - handleRemove(member.id, member.name ?? member.email)} - > - Remove member - - - - ) : ( -
- )} -
- ))} -
- ); - }, - })} -
- - -
-
+ ); + }, + })} + ); } -type DomainData = { - id: string; - domain: string; - state: string; - verificationToken?: string; - verificationPrefix?: string; -}; - -type MemberData = { - id: string; - email: string; - name: string | null; - avatarUrl: string | null; - role: string; - status: string; - lastActiveAt: string | null; - isCurrentUser: boolean; -}; - -type RoleData = { - slug: string; - name: string; -}; - function DomainCard({ domain: d, onDelete }: { domain: DomainData; onDelete: () => void }) { const isVerified = d.state === "verified"; const isPending = d.state === "pending"; @@ -610,145 +255,3 @@ function DomainCard({ domain: d, onDelete }: { domain: DomainData; onDelete: () ); } - -function InviteErrorAlert({ cause }: { cause: Cause.Cause }) { - const failure = Cause.findError(cause); - const error = Result.isSuccess(failure) ? failure.success : null; - - if (error instanceof Forbidden) { - return ( -
-

- You've reached your member limit. Upgrade to Team to invite more. -

- - - -
- ); - } - - return ( -
-

Failed to send invitation. Please try again.

-
- ); -} - -function InviteDialog(props: { - open: boolean; - onOpenChange: (v: boolean) => void; - roles: readonly { slug: string; name: string }[]; -}) { - const [state, dispatch] = useReducer(inviteReducer, initialInviteState); - const doInvite = useAtomSet(inviteMember, { mode: "promiseExit" }); - - const handleInvite = async () => { - if (!state.email.trim()) return; - dispatch({ type: "send" }); - - const exit = await doInvite({ - payload: { - email: state.email.trim(), - ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}), - }, - reactivityKeys: orgMemberWriteKeys, - }); - - if (Exit.isSuccess(exit)) { - toast.success(`Invitation sent to ${state.email.trim()}`); - dispatch({ type: "reset" }); - props.onOpenChange(false); - return; - } - dispatch({ type: "error", cause: exit.cause }); - }; - - return ( - { - if (!v) dispatch({ type: "reset" }); - props.onOpenChange(v); - }} - > - - - Invite member - - Send an email invitation to join your organization. - - - -
-
- - - dispatch({ type: "setEmail", email: (e.target as HTMLInputElement).value }) - } - onKeyDown={(e) => { - if (e.key === "Enter") handleInvite(); - }} - className="text-sm h-9" - /> -
- - {props.roles.length > 0 && ( -
- - -
- )} - - {state.status === "error" && state.failure && } -
- - - - - - - -
-
- ); -} diff --git a/apps/cloud/src/secrets-isolation.e2e.node.test.ts b/apps/cloud/src/secrets-isolation.e2e.node.test.ts index 8bb48326e..fb0c54c8f 100644 --- a/apps/cloud/src/secrets-isolation.e2e.node.test.ts +++ b/apps/cloud/src/secrets-isolation.e2e.node.test.ts @@ -31,7 +31,7 @@ import { Effect, Result } from "effect"; import { ScopeId, SecretId } from "@executor-js/sdk"; -import { asUser, testUserOrgScopeId } from "./services/__test-harness__/api-harness"; +import { asUser, testUserOrgScopeId } from "./testing/api-harness"; const uniq = () => crypto.randomUUID().slice(0, 8); const nextOrgId = () => `org_iso_${uniq()}`; @@ -73,15 +73,15 @@ describe("cloud secret isolation (HTTP, user-org scope stack)", () => { it.effect("users in same org cannot read each other's user-scoped secrets", () => Effect.gen(function* () { - const orgId = nextOrgId(); + const organizationId = nextOrgId(); const aliceId = nextUserId(); const bobId = nextUserId(); const id = `sec_${uniq()}`; // Alice writes at her per-user scope — where OAuth tokens land. - yield* asUser(aliceId, orgId, (client) => + yield* asUser(aliceId, organizationId, (client) => client.secrets.set({ - params: { scopeId: ScopeId.make(testUserOrgScopeId(aliceId, orgId)) }, + params: { scopeId: ScopeId.make(testUserOrgScopeId(aliceId, organizationId)) }, payload: { id: SecretId.make(id), name: "Alice's token", @@ -92,17 +92,17 @@ describe("cloud secret isolation (HTTP, user-org scope stack)", () => { // Bob is in the same org — his user-org scope differs. He should // not see the token in a list. - const bobList = yield* asUser(bobId, orgId, (client) => + const bobList = yield* asUser(bobId, organizationId, (client) => client.secrets.list({ - params: { scopeId: ScopeId.make(testUserOrgScopeId(bobId, orgId)) }, + params: { scopeId: ScopeId.make(testUserOrgScopeId(bobId, organizationId)) }, }), ); expect(bobList.map((s) => s.id)).not.toContain(id); - const bobStatus = yield* asUser(bobId, orgId, (client) => + const bobStatus = yield* asUser(bobId, organizationId, (client) => client.secrets.status({ params: { - scopeId: ScopeId.make(testUserOrgScopeId(bobId, orgId)), + scopeId: ScopeId.make(testUserOrgScopeId(bobId, organizationId)), secretId: SecretId.make(id), }, }), @@ -110,10 +110,10 @@ describe("cloud secret isolation (HTTP, user-org scope stack)", () => { expect(bobStatus.status).toBe("missing"); // And Alice still sees her own token metadata. - const aliceStatus = yield* asUser(aliceId, orgId, (client) => + const aliceStatus = yield* asUser(aliceId, organizationId, (client) => client.secrets.status({ params: { - scopeId: ScopeId.make(testUserOrgScopeId(aliceId, orgId)), + scopeId: ScopeId.make(testUserOrgScopeId(aliceId, organizationId)), secretId: SecretId.make(id), }, }), @@ -124,14 +124,14 @@ describe("cloud secret isolation (HTTP, user-org scope stack)", () => { it.effect("org-scoped secrets are visible to every user in that org", () => Effect.gen(function* () { - const orgId = nextOrgId(); + const organizationId = nextOrgId(); const adminId = nextUserId(); const memberId = nextUserId(); const id = `sec_${uniq()}`; - yield* asUser(adminId, orgId, (client) => + yield* asUser(adminId, organizationId, (client) => client.secrets.set({ - params: { scopeId: ScopeId.make(orgId) }, + params: { scopeId: ScopeId.make(organizationId) }, payload: { id: SecretId.make(id), name: "Org API Key", @@ -140,14 +140,14 @@ describe("cloud secret isolation (HTTP, user-org scope stack)", () => { }), ); - const adminStatus = yield* asUser(adminId, orgId, (client) => + const adminStatus = yield* asUser(adminId, organizationId, (client) => client.secrets.status({ - params: { scopeId: ScopeId.make(orgId), secretId: SecretId.make(id) }, + params: { scopeId: ScopeId.make(organizationId), secretId: SecretId.make(id) }, }), ); - const memberStatus = yield* asUser(memberId, orgId, (client) => + const memberStatus = yield* asUser(memberId, organizationId, (client) => client.secrets.status({ - params: { scopeId: ScopeId.make(orgId), secretId: SecretId.make(id) }, + params: { scopeId: ScopeId.make(organizationId), secretId: SecretId.make(id) }, }), ); expect(adminStatus.status).toBe("resolved"); @@ -209,11 +209,11 @@ describe("cloud secret isolation (HTTP, user-org scope stack)", () => { it.effect("secrets.set rejects a scope outside the executor's stack", () => Effect.gen(function* () { - const orgId = nextOrgId(); + const organizationId = nextOrgId(); const userId = nextUserId(); const foreignOrg = nextOrgId(); - const result = yield* asUser(userId, orgId, (client) => + const result = yield* asUser(userId, organizationId, (client) => client.secrets .set({ params: { scopeId: ScopeId.make(foreignOrg) }, diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 0ca7bcd6a..e941de78d 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -9,8 +9,8 @@ import { import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; -import { McpSessionDO as McpSessionDOBase } from "./mcp-session"; -import { flushTracerProvider, installTracerProvider } from "./services/telemetry"; +import { McpSessionDO as McpSessionDOBase } from "./mcp/session-durable-object"; +import { flushTracerProvider, installTracerProvider } from "./observability/telemetry"; // --------------------------------------------------------------------------- // Sentry config @@ -32,7 +32,7 @@ const sentryOptions = (env: Env) => ({ // --------------------------------------------------------------------------- // Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the // client inside the DO isolate, which plain `Sentry.captureException` cannot -// do on its own). OTEL is installed through Effect layers (services/telemetry), +// do on its own). OTEL is installed through Effect layers (observability/telemetry), // not a global fetch wrapper. // --------------------------------------------------------------------------- @@ -45,7 +45,7 @@ export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry( // Worker fetch handler // // We open a single `http.server ` span at the worker boundary using -// the same WebTracerProvider that `services/telemetry.ts` already installs for +// the same WebTracerProvider that `observability/telemetry.ts` already installs for // Effect-driven spans. This restores the per-request envelope span that was // previously emitted by `@microlabs/otel-cf-workers` and lost in the alchemy // migration — without the OTel-SDK version-conflict that package would now diff --git a/apps/cloud/src/services/autumn-plans.ts b/apps/cloud/src/services/autumn-plans.ts deleted file mode 100644 index 9e112a945..000000000 --- a/apps/cloud/src/services/autumn-plans.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { enterprise, team } from "../../autumn.config"; - -export const PAID_AUTUMN_PLAN_IDS = new Set([team.id, enterprise.id]); - -export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]); diff --git a/apps/cloud/src/services/execution-stack.ts b/apps/cloud/src/services/execution-stack.ts deleted file mode 100644 index d901416c1..000000000 --- a/apps/cloud/src/services/execution-stack.ts +++ /dev/null @@ -1,34 +0,0 @@ -// --------------------------------------------------------------------------- -// Shared execution stack — the wiring that turns an organization into a -// runnable executor + engine. Used by the protected HTTP API (per-request) -// and the MCP session DO (per-session) so changes to the stack flow to both. -// --------------------------------------------------------------------------- - -import { env } from "cloudflare:workers"; -import { Effect } from "effect"; - -import { createExecutionEngine } from "@executor-js/execution"; -import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; - -import { withExecutionUsageTracking } from "../api/execution-usage"; -import { AutumnService } from "./autumn"; -import { createScopedExecutor } from "./executor"; - -export const makeExecutionStack = ( - userId: string, - organizationId: string, - organizationName: string, -) => - Effect.gen(function* () { - const executor = yield* createScopedExecutor(userId, organizationId, organizationName).pipe( - Effect.withSpan("McpSessionDO.createScopedExecutor"), - ); - const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); - const autumn = yield* AutumnService; - const engine = withExecutionUsageTracking( - organizationId, - createExecutionEngine({ executor, codeExecutor }), - (orgId) => Effect.runFork(autumn.trackExecution(orgId)), - ); - return { executor, engine }; - }).pipe(Effect.withSpan("McpSessionDO.makeExecutionStack")); diff --git a/apps/cloud/src/services/executor-schema.ts b/apps/cloud/src/services/executor-schema.ts deleted file mode 100644 index 57beba20f..000000000 --- a/apps/cloud/src/services/executor-schema.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { pgTable, text, boolean, timestamp, varchar, uniqueIndex, json, bigint } from "drizzle-orm/pg-core" -import { createId } from "fumadb/cuid" - -export const source = pgTable("source", { - plugin_id: text("plugin_id").notNull(), - kind: text("kind").notNull(), - name: text("name").notNull(), - url: text("url"), - can_remove: boolean("can_remove").notNull().default(true), - can_refresh: boolean("can_refresh").notNull().default(false), - can_edit: boolean("can_edit").notNull().default(false), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("source_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const tool = pgTable("tool", { - source_id: text("source_id").notNull(), - plugin_id: text("plugin_id").notNull(), - name: text("name").notNull(), - description: text("description").notNull(), - input_schema: json("input_schema"), - output_schema: json("output_schema"), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("tool_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const definition = pgTable("definition", { - source_id: text("source_id").notNull(), - plugin_id: text("plugin_id").notNull(), - name: text("name").notNull(), - schema: json("schema").notNull(), - created_at: timestamp("created_at").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("definition_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const secret = pgTable("secret", { - name: text("name").notNull(), - provider: text("provider").notNull(), - owned_by_connection_id: text("owned_by_connection_id"), - created_at: timestamp("created_at").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("secret_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const connection = pgTable("connection", { - provider: text("provider").notNull(), - identity_label: text("identity_label"), - access_token_secret_id: text("access_token_secret_id").notNull(), - refresh_token_secret_id: text("refresh_token_secret_id"), - expires_at: bigint("expires_at", { mode: "bigint" }), - scope: text("scope"), - provider_state: json("provider_state"), - identity_override: json("identity_override"), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("connection_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const oauth2_session = pgTable("oauth2_session", { - plugin_id: text("plugin_id").notNull(), - strategy: text("strategy").notNull(), - connection_id: text("connection_id").notNull(), - token_scope: text("token_scope").notNull(), - redirect_url: text("redirect_url").notNull(), - payload: json("payload").notNull(), - expires_at: bigint("expires_at", { mode: "bigint" }).notNull(), - created_at: timestamp("created_at").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("oauth2_session_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const credential_binding = pgTable("credential_binding", { - plugin_id: text("plugin_id").notNull(), - source_id: text("source_id").notNull(), - source_scope_id: text("source_scope_id").notNull(), - slot_key: text("slot_key").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - secret_id: text("secret_id"), - secret_scope_id: text("secret_scope_id"), - connection_id: text("connection_id"), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("credential_binding_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const plugin_storage = pgTable("plugin_storage", { - plugin_id: text("plugin_id").notNull(), - collection: text("collection").notNull(), - key: text("key").notNull(), - data: json("data").notNull(), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("plugin_storage_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const tool_policy = pgTable("tool_policy", { - pattern: text("pattern").notNull(), - action: text("action").notNull(), - position: text("position").notNull(), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("tool_policy_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const blob = pgTable("blob", { - namespace: text("namespace").notNull(), - key: text("key").notNull(), - value: text("value").notNull(), - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull() -}, (table) => [ - uniqueIndex("blob_id_uidx").on(table.id) -]) - -export const private_executor_cloud_settings = pgTable("private_executor_cloud_settings", { - id: varchar("id", { length: 255 }).primaryKey().notNull(), - version: varchar("version", { length: 255 }).notNull().default("1.0.0") -}) \ No newline at end of file diff --git a/apps/cloud/src/services/executor.ts b/apps/cloud/src/services/executor.ts deleted file mode 100644 index 049ea1444..000000000 --- a/apps/cloud/src/services/executor.ts +++ /dev/null @@ -1,102 +0,0 @@ -// --------------------------------------------------------------------------- -// Cloud executor — stateless, per-request, new SDK shape -// --------------------------------------------------------------------------- -// -// Each invocation of `createScopedExecutor` runs inside a request-scoped -// Effect and yields a fresh executor bound to the current DbService's -// per-request postgres.js client. Cloudflare Workers + Hyperdrive demand -// fresh connections per request, so "build once" means "once per request" -// here. - -import { Effect } from "effect"; - -import { - Scope, - ScopeId, - collectTables, - createExecutor, - makeHostedHttpClientLayer, -} from "@executor-js/sdk"; - -import { env } from "cloudflare:workers"; -import executorConfig from "../../executor.config"; -import { DbService } from "./db"; -import { createDrizzleFumaDb } from "./fuma"; - -// --------------------------------------------------------------------------- -// Plugin list lives in `executor.config.ts` — that file is the single source -// of truth for runtime, schema wiring, and the test harness. Per-request -// runtime values (WorkOS credentials from the Worker env) are passed through -// the factory's `deps` parameter. -// --------------------------------------------------------------------------- - -export type CloudPlugins = ReturnType; - -const orgPlugins = (): CloudPlugins => - executorConfig.plugins({ - workosCredentials: { - apiKey: env.WORKOS_API_KEY, - clientId: env.WORKOS_CLIENT_ID, - }, - }); - -// --------------------------------------------------------------------------- -// Create a fresh executor for a (user, org) pair (stateless, per-request). -// -// Scope stack is `[userOrgScope, orgScope]` — innermost first. The -// user-within-org scope id (`user-org:${userId}:${orgId}`) intentionally -// includes the org id so the same WorkOS user in a different org gets a -// distinct scope row; future workspace scopes can slot in between without -// conflicting with a hypothetical global user scope. -// -// OAuth token writes require an explicit `tokenScope`. User sign-in UI passes -// the user-org scope so a member's access/refresh tokens cannot leak to other -// members via `secrets.list`, while source rows and org-wide credentials live -// on the outer scope. -// --------------------------------------------------------------------------- - -export const createScopedExecutor = ( - userId: string, - organizationId: string, - organizationName: string, -) => - Effect.gen(function* () { - const { db } = yield* DbService; - - const plugins = orgPlugins(); - const httpClientLayer = makeHostedHttpClientLayer({ - allowLocalNetwork: env.NODE_ENV === "test", - }); - const fuma = createDrizzleFumaDb({ - db, - tables: collectTables(plugins), - namespace: "executor_cloud", - provider: "postgresql", - }); - - const orgScope = Scope.make({ - id: ScopeId.make(organizationId), - name: organizationName, - createdAt: new Date(), - }); - const userOrgScope = Scope.make({ - id: ScopeId.make(`user-org:${userId}:${organizationId}`), - name: `Personal · ${organizationName}`, - createdAt: new Date(), - }); - - // The executor surface returns raw `StorageFailure`; translation to - // the opaque `InternalError({ traceId })` happens at the HTTP edge - // via `withCapture` (see `api/protected-layers.ts`). That's - // where `ErrorCaptureLive` (Sentry) gets wired in. - return yield* createExecutor({ - scopes: [userOrgScope, orgScope], - db: fuma.db, - plugins, - httpClientLayer, - onElicitation: "accept-all", - coreTools: { - webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", - }, - }); - }); diff --git a/apps/cloud/src/services/fuma.ts b/apps/cloud/src/services/fuma.ts deleted file mode 100644 index 8753dcfbd..000000000 --- a/apps/cloud/src/services/fuma.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { fumadb, type FumaDB } from "fumadb"; -import { drizzleAdapter, type DrizzleConfig } from "fumadb/adapters/drizzle"; -import { schema as fumaSchema, type RelationsMap } from "fumadb/schema"; - -import type { FumaDb, FumaTables } from "@executor-js/sdk"; - -type DrizzleFumaSchema = ReturnType< - typeof fumaSchema> ->; - -export interface DrizzleFumaDb { - readonly db: FumaDb>; - readonly fuma: FumaDB[]>; -} - -export interface CreateDrizzleFumaDbOptions { - readonly db: DrizzleConfig["db"]; - readonly tables: TTables; - readonly namespace: string; - readonly version?: string; - readonly provider: DrizzleConfig["provider"]; -} - -export const createDrizzleFumaDb = ( - options: CreateDrizzleFumaDbOptions, -): DrizzleFumaDb => { - const version = options.version ?? "1.0.0"; - const latestSchema = fumaSchema({ - version, - tables: options.tables, - }); - const factory = fumadb({ - namespace: options.namespace, - schemas: [latestSchema], - }); - const fuma = factory.client( - drizzleAdapter({ - db: options.db, - provider: options.provider, - }), - ); - - return { - db: fuma.orm(version), - fuma, - }; -}; diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index ac2a07b13..d8d8f15c1 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -1,158 +1,55 @@ -import { env } from "cloudflare:workers"; import { createMiddleware, createStart } from "@tanstack/react-start"; -import { Effect } from "effect"; -import { handleApiRequest } from "./api"; -import { classifyMcpPath, mcpFetch } from "./mcp"; -import { handleSentryTunnelRequest } from "./sentry-tunnel"; -// --------------------------------------------------------------------------- -// Marketing routes — proxied to the marketing worker via service binding -// --------------------------------------------------------------------------- - -const MARKETING_PATHS = [ - "/home", - "/setup", - "/privacy", - "/terms", - "/api/detect", - "/_astro", - "/og-image.png", - "/pattern-graph-paper.svg", -]; - -const isMarketingPath = (pathname: string) => - MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); - -const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined; - -const marketingMiddleware = createMiddleware({ type: "request" }).server( - async ({ pathname, request, next }) => { - // Only proxy to the marketing worker on the production domain. In local - // dev we don't run `executor-marketing`, so unauthenticated visits fall - // through to the cloud app's routes (which show the sign-in page). - const host = new URL(request.url).hostname; - if (host !== "executor.sh") return next(); - - const shouldProxyToMarketing = - isMarketingPath(pathname) || - (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session")); - - if (!shouldProxyToMarketing) return next(); - - const marketing = getMarketingWorker(); - if (!marketing) return next(); - - const url = new URL(request.url); - // Rewrite /home to / so marketing worker serves its homepage - if (pathname === "/home") { - url.pathname = "/"; - } - return marketing.fetch(new Request(url, request)); - }, -); - -const parseCookie = (cookieHeader: string | null, name: string): string | null => { - if (!cookieHeader) return null; - const match = cookieHeader - .split(";") - .map((v) => v.trim()) - .find((v) => v.startsWith(`${name}=`)); - return match ? match.slice(name.length + 1) || null : null; -}; - -// --------------------------------------------------------------------------- -// MCP middleware — routes /mcp and /.well-known/* to the MCP handler -// --------------------------------------------------------------------------- - -const mcpRequestMiddleware = createMiddleware({ type: "request" }).server( - async ({ pathname, request, next }) => { - // Single source of truth for MCP path ownership (incl. `/org_xxx/mcp` and - // the org-scoped `.well-known` resource metadata). `mcpFetch` re-checks and - // can still return null, in which case we fall through to TanStack routing. - if (classifyMcpPath(pathname) !== null) { - const response = await mcpFetch(request); - if (response) return response; - } - return next(); - }, -); - -// --------------------------------------------------------------------------- -// Sentry tunnel — the browser SDK POSTs envelopes to /api/sentry-tunnel -// (configured in routes/__root.tsx) to dodge adblockers and CSP. We parse -// the envelope header to recover the DSN, validate against our own, and -// forward the body to Sentry's ingest endpoint. See -// https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option -// --------------------------------------------------------------------------- - -const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server( - ({ pathname, request, next }) => { - if (pathname !== "/api/sentry-tunnel" || request.method !== "POST") { - return next(); - } - - const configuredDsn = (env as { SENTRY_DSN?: string }).SENTRY_DSN; - if (!configuredDsn) return new Response(null, { status: 204 }); - - return Effect.runPromise(handleSentryTunnelRequest(request, configuredDsn)); - }, -); - -// --------------------------------------------------------------------------- -// PostHog reverse proxy — the browser SDK targets a build-randomized -// first-party path and we forward to PostHog's ingest + asset hosts. Keeps -// events flowing past adblockers that match *.posthog.com. See -// https://posthog.com/docs/advanced/proxy/cloudflare -// --------------------------------------------------------------------------- - -const POSTHOG_INGEST_HOST = "us.i.posthog.com"; -const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com"; -const POSTHOG_PROXY_PATH = `/api/${(import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a").replace( - /^\/+|\/+$/g, - "", -)}`; - -const posthogProxyMiddleware = createMiddleware({ type: "request" }).server( - ({ pathname, request, next }) => { - if (pathname !== POSTHOG_PROXY_PATH && !pathname.startsWith(`${POSTHOG_PROXY_PATH}/`)) { - return next(); - } - - const url = new URL(request.url); - url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`) - ? POSTHOG_ASSETS_HOST - : POSTHOG_INGEST_HOST; - url.protocol = "https:"; - url.port = ""; - url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/"; - - const upstream = new Request(url, request); - upstream.headers.delete("cookie"); - return fetch(upstream); - }, -); - -// --------------------------------------------------------------------------- -// API middleware — routes /api/* to the Effect HTTP layer -// --------------------------------------------------------------------------- - -const apiRequestMiddleware = createMiddleware({ type: "request" }).server( +import { cloudApiHandler } from "./app"; +import { isAppOwnedPath } from "./app-paths"; +import { prepareMcpOrgScope } from "./mcp/mount"; +import { marketingMiddleware, posthogProxyMiddleware, sentryTunnelMiddleware } from "./edge"; + +// --------------------------------------------------------------------------- +// The unified app web handler — `ExecutorApp.make`'s `toWebHandler` (app.ts). +// It serves EVERY app-owned path in one Effect HTTP layer: everything under +// `/api/*` (the protected plugin API + account + org, plus the cloud +// `extensions.routes` — Swagger at `/api/docs`, the Autumn billing proxy at +// `/api/billing/*`), AND the `/mcp` serving envelope + its `/.well-known/*` +// OAuth discovery docs — exactly like self-host's single `toWebHandler`. +// start.ts no longer hand-routes those surfaces; it only decides +// app-owned-vs-Start and forwards (after normalizing org-scoped MCP paths). +// --------------------------------------------------------------------------- + +// Instantiate the unified app handler LAZILY, on the first server request that +// needs it. This is load-bearing for the CLIENT bundle: TanStack Start bundles +// `start.ts` into the browser build but strips `.server()` callback *bodies*, so +// any symbol referenced only inside a server callback is tree-shaken out of the +// client. A module-top-level `cloudApiHandler()` would instead survive that +// stripping and drag `./app` → `observability/telemetry` → `cloudflare:workers` +// (a workerd-only virtual module) into the browser build, breaking it. Keeping +// the call inside the server callback mirrors how every other server concern +// here stays server-only. +let app: ReturnType | undefined; +const getApp = () => (app ??= cloudApiHandler()); + +// app-owned = anything under `/api/*` (incl. the cloud extension routes) OR an +// MCP/OAuth-discovery path (see `./app-paths`). The app handler serves these at +// their real paths, so we forward unmodified — except `prepareMcpOrgScope` +// rewrites an org-scoped MCP path (`/org_xxx/mcp`) to the bare path the shared +// envelope routes, pinning the org in an internal header (a no-op for everything +// else, including `/api/*`). +const appRequestMiddleware = createMiddleware({ type: "request" }).server( ({ pathname, request, next }) => { - if (pathname === "/api" || pathname.startsWith("/api/")) { - const url = new URL(request.url); - url.pathname = url.pathname.replace(/^\/api/, ""); - return handleApiRequest(new Request(url, request)); - } + if (isAppOwnedPath(pathname)) return getApp().handler(prepareMcpOrgScope(request)); return next(); }, ); +// The edge concerns (marketing proxy, sentry tunnel, posthog proxy) live in +// `./edge`; they run before the app's own dispatch. Ordering is load-bearing: +// marketing first (production landing/page proxy), then the analytics tunnels, +// then the unified app plane (api + mcp). export const startInstance = createStart(() => ({ requestMiddleware: [ marketingMiddleware, - mcpRequestMiddleware, sentryTunnelMiddleware, posthogProxyMiddleware, - apiRequestMiddleware, + appRequestMiddleware, ], })); diff --git a/apps/cloud/src/services/__test-harness__/api-harness.ts b/apps/cloud/src/testing/api-harness.ts similarity index 76% rename from apps/cloud/src/services/__test-harness__/api-harness.ts rename to apps/cloud/src/testing/api-harness.ts index 213d6abf5..c26cfab80 100644 --- a/apps/cloud/src/services/__test-harness__/api-harness.ts +++ b/apps/cloud/src/testing/api-harness.ts @@ -9,7 +9,7 @@ // - `workos-vault` is configured with an in-memory `WorkOSVaultClient` // so secret writes never reach WorkOS's real API. // -// Tests get a `fetchForOrg(orgId)` they can hand to `FetchHttpClient` +// Tests get a `fetchForOrg(organizationId)` they can hand to `FetchHttpClient` // and then call `HttpApiClient.make(ProtectedCloudApi)` against it. // Each test picks its own org id (usually a random UUID) so rows don't // collide across tests. @@ -21,38 +21,31 @@ import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effe import { ExecutionEngineService, ExecutorService, + collectTables, providePluginExtensions, type PluginExtensionServices, } from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; -import { Scope, ScopeId, collectTables, createExecutor } from "@executor-js/sdk"; +import { createExecutor, makeUserOrgScopeStack, userOrgScopeId } from "@executor-js/sdk"; import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; -import executorConfig from "../../../executor.config"; -import { AuthContext } from "../../auth/middleware"; -import { - ProtectedCloudApi, - ProtectedCloudApiHandlers, - RouterConfig, -} from "../../api/protected-layers"; -import { DbService } from "../db"; -import { createDrizzleFumaDb } from "../fuma"; +import executorConfig from "../../executor.config"; +import { AuthContext, RouterConfigLive } from "@executor-js/api/server"; + +import { ProtectedCloudApi, ProtectedCloudApiHandlers } from "../api/layers"; +import { DbService } from "../db/db"; +import { createDrizzleFumaDb } from "../db/fuma"; export const TEST_BASE_URL = "http://test.local"; export const TEST_ORG_HEADER = "x-test-org-id"; export const TEST_USER_HEADER = "x-test-user-id"; -// Mirrors apps/cloud/src/services/executor.ts#createScopedExecutor — the -// per-user scope id bakes in the org so the same user id in a different -// org gets a distinct scope row. -const userOrgScopeId = (userId: string, orgId: string) => `user-org:${userId}:${orgId}`; - -// `asOrg(orgId, …)` callers don't care which specific user they are, only +// `asOrg(organizationId, …)` callers don't care which specific user they are, only // that the executor has a valid user-org scope. We give each org a stable // default user so list/get operations at the org scope remain deterministic // across calls within a single test. -const defaultUserFor = (orgId: string) => `default_user_${orgId}`; +const defaultUserFor = (organizationId: string) => `default_user_${organizationId}`; // --------------------------------------------------------------------------- // Executor factory — mirrors apps/cloud/services/executor#createScopedExecutor @@ -66,28 +59,23 @@ const testPlugins = executorConfig.plugins({ }); const testHttpClientLayer = FetchHttpClient.layer; -const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) => +const createTestScopedExecutor = ( + userId: string, + organizationId: string, + organizationName: string, +) => Effect.gen(function* () { const { db } = yield* DbService; const plugins = testPlugins; const fuma = createDrizzleFumaDb({ db, - tables: collectTables(plugins), + tables: collectTables(), namespace: "executor_cloud", provider: "postgresql", }); - const orgScope = Scope.make({ - id: ScopeId.make(orgId), - name: orgName, - createdAt: new Date(), - }); - const userOrgScope = Scope.make({ - id: ScopeId.make(userOrgScopeId(userId, orgId)), - name: `Personal · ${orgName}`, - createdAt: new Date(), - }); + const scopes = makeUserOrgScopeStack(userId, organizationId, organizationName); return yield* createExecutor({ - scopes: [userOrgScope, orgScope], + scopes, db: fuma.db, plugins, httpClientLayer: testHttpClientLayer, @@ -121,8 +109,8 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ return (httpEffect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const orgId = request.headers[TEST_ORG_HEADER]; - if (!orgId || typeof orgId !== "string") { + const organizationId = request.headers[TEST_ORG_HEADER]; + if (!organizationId || typeof organizationId !== "string") { // oxlint-disable-next-line executor/no-effect-escape-hatch, executor/no-error-constructor -- boundary: test HTTP harness has no request context without x-test-org-id return yield* Effect.die(new Error("missing x-test-org-id")); } @@ -130,9 +118,9 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ const userId = typeof userHeader === "string" && userHeader.length > 0 ? userHeader - : defaultUserFor(orgId); - const orgName = `Org ${orgId}`; - const executor = yield* createTestScopedExecutor(userId, orgId, orgName); + : defaultUserFor(organizationId); + const organizationName = `Org ${organizationId}`; + const executor = yield* createTestScopedExecutor(userId, organizationId, organizationName); const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor(), @@ -142,10 +130,11 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ AuthContext, AuthContext.of({ accountId: userId, - organizationId: orgId, + organizationId, email: "test@example.com", name: "Test User", avatarUrl: null, + roles: [], }), ), Effect.provideService(ExecutorService, executor), @@ -160,43 +149,43 @@ const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( Layer.provide(ProtectedCloudApiHandlers), Layer.provide(TestExecutionStackMiddleware), Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), - Layer.provideMerge(RouterConfig), + Layer.provideMerge(RouterConfigLive), Layer.provideMerge(DbService.Live), Layer.provideMerge(HttpServer.layerServices), ); const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler; -export const fetchForOrg = (orgId: string): typeof globalThis.fetch => +export const fetchForOrg = (organizationId: string): typeof globalThis.fetch => ((input: RequestInfo | URL, init?: RequestInit) => { const base = input instanceof Request ? input : new Request(input, init); const req = new Request(base, { - headers: { ...Object.fromEntries(base.headers), [TEST_ORG_HEADER]: orgId }, + headers: { ...Object.fromEntries(base.headers), [TEST_ORG_HEADER]: organizationId }, }); return handler(req); }) as typeof globalThis.fetch; -export const fetchForUser = (userId: string, orgId: string): typeof globalThis.fetch => +export const fetchForUser = (userId: string, organizationId: string): typeof globalThis.fetch => ((input: RequestInfo | URL, init?: RequestInit) => { const base = input instanceof Request ? input : new Request(input, init); const req = new Request(base, { headers: { ...Object.fromEntries(base.headers), - [TEST_ORG_HEADER]: orgId, + [TEST_ORG_HEADER]: organizationId, [TEST_USER_HEADER]: userId, }, }); return handler(req); }) as typeof globalThis.fetch; -export const clientLayerForOrg = (orgId: string) => +export const clientLayerForOrg = (organizationId: string) => FetchHttpClient.layer.pipe( - Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchForOrg(orgId))), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchForOrg(organizationId))), ); -export const clientLayerForUser = (userId: string, orgId: string) => +export const clientLayerForUser = (userId: string, organizationId: string) => FetchHttpClient.layer.pipe( - Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchForUser(userId, orgId))), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchForUser(userId, organizationId))), ); // Constructs an HttpApiClient bound to the given org, hands it to `body`, @@ -205,31 +194,32 @@ export const clientLayerForUser = (userId: string, orgId: string) => type ApiShape = HttpApiClient.ForApi; export const asOrg = ( - orgId: string, + organizationId: string, body: (client: ApiShape) => Effect.Effect, ): Effect.Effect => Effect.gen(function* () { const client = yield* HttpApiClient.make(ProtectedCloudApi, { baseUrl: TEST_BASE_URL }); return yield* body(client); - }).pipe(Effect.provide(clientLayerForOrg(orgId))) as Effect.Effect; + }).pipe(Effect.provide(clientLayerForOrg(organizationId))) as Effect.Effect; // Same as `asOrg` but also threads a specific user id through the fake // OrgAuth, so the built executor's user-org scope id is -// `user-org:${userId}:${orgId}`. Use this for tests that care about +// `user-org:${userId}:${organizationId}`. Use this for tests that care about // per-user isolation inside the same org. export const asUser = ( userId: string, - orgId: string, + organizationId: string, body: (client: ApiShape) => Effect.Effect, ): Effect.Effect => Effect.gen(function* () { const client = yield* HttpApiClient.make(ProtectedCloudApi, { baseUrl: TEST_BASE_URL }); return yield* body(client); - }).pipe(Effect.provide(clientLayerForUser(userId, orgId))) as Effect.Effect; + }).pipe(Effect.provide(clientLayerForUser(userId, organizationId))) as Effect.Effect; // Exposed so tests can build the same user-org scope id the harness uses // when writing at a specific user's scope. -export const testUserOrgScopeId = (userId: string, orgId: string) => userOrgScopeId(userId, orgId); +export const testUserOrgScopeId = (userId: string, organizationId: string) => + userOrgScopeId(userId, organizationId); // Re-exports so call sites don't need a second import. export { ProtectedCloudApi }; diff --git a/apps/cloud/src/test-bearer.ts b/apps/cloud/src/testing/test-bearer.ts similarity index 95% rename from apps/cloud/src/test-bearer.ts rename to apps/cloud/src/testing/test-bearer.ts index 1c1e92f0b..d7dea789a 100644 --- a/apps/cloud/src/test-bearer.ts +++ b/apps/cloud/src/testing/test-bearer.ts @@ -3,7 +3,7 @@ // zero-dependency module so node tests can pull it without dragging in the // worker entry, which imports `cloudflare:workers`. -import type { VerifiedToken } from "./mcp-auth"; +import type { VerifiedToken } from "../mcp/jwt"; export const TEST_BEARER_PREFIX = "test-accept::"; export const NO_ORG_SENTINEL = "none"; diff --git a/apps/cloud/src/test-worker.ts b/apps/cloud/src/testing/test-worker.ts similarity index 79% rename from apps/cloud/src/test-worker.ts rename to apps/cloud/src/testing/test-worker.ts index 142c22eb5..32652b904 100644 --- a/apps/cloud/src/test-worker.ts +++ b/apps/cloud/src/testing/test-worker.ts @@ -13,7 +13,6 @@ // load — that was SIGSEGV-ing workerd during test instantiation. // --------------------------------------------------------------------------- -import { HttpEffect } from "effect/unstable/http"; import { Effect, Layer } from "effect"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres, { type Sql } from "postgres"; @@ -21,21 +20,21 @@ import postgres, { type Sql } from "postgres"; import { McpAuth, McpAuthLive, + McpJwtVerificationError, McpOrganizationAuth, McpOrganizationAuthLive, - classifyMcpPath, mcpAuthorized, - mcpApp, mcpUnauthorized, -} from "./mcp"; -import { ApiKeyService } from "./auth/api-keys"; -import { McpJwtVerificationError } from "./mcp-auth"; -import { organizations } from "./services/schema"; +} from "../mcp/auth"; +import { classifyMcpPath, makeMcpWebHandler, prepareMcpOrgScope } from "../mcp/mount"; +import { cloudMcpAuthProviderLayer } from "../mcp/auth-provider"; +import { ApiKeyService } from "../auth/api-keys"; +import { organizations } from "../db/schema"; import { parseTestBearer } from "./test-bearer"; -import { DoTelemetryLive } from "./services/telemetry"; -import { CoreSharedServices } from "./api/core-shared-services"; +import { DoTelemetryLive } from "../observability/telemetry"; +import { CoreSharedServices } from "../auth/workos"; -export { McpSessionDO } from "./mcp-session"; +export { McpSessionDO } from "../mcp/session-durable-object"; const TestMcpAuthLive = Layer.succeed(McpAuth)({ verifyBearer: (request) => @@ -109,28 +108,24 @@ const handleSeedOrg = async ( return new Response(null, { status: 204 }); }; -// Provide a WebSdk-backed tracer on the worker side so the `mcp.request` span -// gets reported to the OTLP receiver. This is the same Worker-safe telemetry -// layer used in prod. -const testMcpFetch = HttpEffect.toWebHandler( - mcpApp.pipe( - Effect.provide(Layer.mergeAll(TestMcpAuthLive, TestMcpOrganizationAuthLive, DoTelemetryLive)), - ), -); +// Build the same shared host-mcp envelope handler the prod worker uses, with +// the test auth seams swapped in. A WebSdk-backed tracer (DoTelemetryLive) is +// provided to the whole router so the `mcp.request` / `mcp.request.annotate` +// spans get reported to the OTLP receiver. +const testMcpFetch = makeMcpWebHandler({ + authProvider: cloudMcpAuthProviderLayer, + seamsRequirements: Layer.mergeAll(TestMcpAuthLive, TestMcpOrganizationAuthLive), + runtime: DoTelemetryLive, +}); -const realAuthMcpFetch = HttpEffect.toWebHandler( - mcpApp.pipe( - Effect.provide( - Layer.mergeAll( - McpAuthLive.pipe( - Layer.provide(ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices))), - ), - McpOrganizationAuthLive, - DoTelemetryLive, - ), - ), +const realAuthMcpFetch = makeMcpWebHandler({ + authProvider: cloudMcpAuthProviderLayer, + seamsRequirements: Layer.mergeAll( + McpAuthLive.pipe(Layer.provide(ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices)))), + McpOrganizationAuthLive, ), -); + runtime: DoTelemetryLive, +}); export default { async fetch(request: Request, envArg: Record): Promise { @@ -144,7 +139,7 @@ export default { return realAuthMcpFetch(new Request(mcpUrl, request)); } if (classifyMcpPath(url.pathname) !== null) { - return testMcpFetch(request); + return testMcpFetch(prepareMcpOrgScope(request)); } return new Response("not found", { status: 404 }); }, diff --git a/apps/cloud/src/web/api-key-atoms.ts b/apps/cloud/src/web/api-key-atoms.ts deleted file mode 100644 index b3b6cd9f8..000000000 --- a/apps/cloud/src/web/api-key-atoms.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; -import { CloudApiClient } from "./client"; - -export const apiKeysAtom = CloudApiClient.query("cloudAuth", "listApiKeys", { - reactivityKeys: [ReactivityKey.apiKeys], -}); - -export const createApiKey = CloudApiClient.mutation("cloudAuth", "createApiKey"); -export const revokeApiKey = CloudApiClient.mutation("cloudAuth", "revokeApiKey"); diff --git a/apps/cloud/src/web/auth.tsx b/apps/cloud/src/web/auth.tsx index 457fee83c..3f7f3be93 100644 --- a/apps/cloud/src/web/auth.tsx +++ b/apps/cloud/src/web/auth.tsx @@ -1,36 +1,29 @@ -import React, { createContext, useContext, useEffect } from "react"; +import React from "react"; import * as Atom from "effect/unstable/reactivity/Atom"; -import { useAtomValue } from "@effect/atom-react"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { usePostHog } from "posthog-js/react"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; +import { + AuthProvider as SharedAuthProvider, + useAuth, + type IdentifyFn, +} from "@executor-js/react/multiplayer/auth-context"; import { CloudApiClient } from "./client"; // --------------------------------------------------------------------------- -// Types (from CloudAuthApi response schema) +// Cloud auth — the SHARED multiplayer auth seam (`useAuth` reads `/account/me`) +// with a thin cloud wrapper that wires PostHog identify/group/reset through the +// shared `onIdentify` callback. Identity comes from the provider-neutral +// account surface, identical to self-host. +// +// Cloud-only multi-org bits (org switcher, create-org, pending invites) stay +// here as cloud-local atoms over CloudApiClient — they are NOT part of the +// shared account contract and coexist with the shared `/account/*` atoms. // --------------------------------------------------------------------------- -type AuthUser = { - id: string; - email: string; - name: string | null; - avatarUrl: string | null; -}; - -type AuthOrganization = { - id: string; - name: string; -}; - -// --------------------------------------------------------------------------- -// Auth atom — typed query against CloudAuthApi -// --------------------------------------------------------------------------- +export { useAuth }; -export const authAtom = CloudApiClient.query("cloudAuth", "me", { - timeToLive: "5 minutes", - reactivityKeys: [ReactivityKey.auth], -}); +// ── Cloud-only multi-org atoms (CloudAuthApi) ────────────────────────────── export const organizationsAtom = Atom.refreshOnWindowFocus( CloudApiClient.query("cloudAuth", "organizations", { @@ -49,58 +42,27 @@ export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pending export const acceptInvitation = CloudApiClient.mutation("cloudAuth", "acceptInvitation"); -// --------------------------------------------------------------------------- -// Provider + hook -// --------------------------------------------------------------------------- +// ── Provider ─────────────────────────────────────────────────────────────── -type AuthState = - | { status: "loading" } - | { status: "unauthenticated" } - | { status: "authenticated"; user: AuthUser; organization: AuthOrganization | null }; - -const AuthContext = createContext({ status: "loading" }); - -export const useAuth = () => useContext(AuthContext); - -const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { - const result = useAtomValue(authAtom); +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const posthog = usePostHog(); - const state: AuthState = AsyncResult.match(result, { - onInitial: () => ({ status: "loading" as const }), - onSuccess: ({ value }) => ({ - status: "authenticated" as const, - user: value.user, - organization: value.organization, - }), - onFailure: () => ({ status: "unauthenticated" as const }), - }); - - const userId = state.status === "authenticated" ? state.user.id : null; - const email = state.status === "authenticated" ? state.user.email : null; - const name = state.status === "authenticated" ? state.user.name : null; - const orgId = state.status === "authenticated" ? (state.organization?.id ?? null) : null; - const orgName = state.status === "authenticated" ? (state.organization?.name ?? null) : null; - const isUnauthenticated = state.status === "unauthenticated"; - - useEffect(() => { - if (!posthog) return; - if (userId) { - posthog.identify(userId, { email, name }); - if (orgId) { - posthog.group("organization", orgId, { name: orgName }); + const onIdentify = React.useCallback( + (state) => { + if (!posthog) return; + if (state.status === "authenticated") { + posthog.identify(state.user.id, { email: state.user.email, name: state.user.name }); + if (state.organization) { + posthog.group("organization", state.organization.id, { + name: state.organization.name, + }); + } + } else { + posthog.reset(); } - } else if (isUnauthenticated) { - posthog.reset(); - } - }, [posthog, userId, email, name, orgId, orgName, isUnauthenticated]); - - return {children}; -}; + }, + [posthog], + ); -export const AuthProvider = ({ children }: { children: React.ReactNode }) => { - if (typeof window === "undefined") { - return {children}; - } - return {children}; + return {children}; }; diff --git a/apps/cloud/src/web/components/org-menu-slot.tsx b/apps/cloud/src/web/components/org-menu-slot.tsx new file mode 100644 index 000000000..e144ccbbf --- /dev/null +++ b/apps/cloud/src/web/components/org-menu-slot.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { useAtomValue, useAtomSet } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Exit from "effect/Exit"; +import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { Button } from "@executor-js/react/components/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@executor-js/react/components/dropdown-menu"; +import { useAuth } from "../auth"; +import { organizationsAtom, switchOrganization } from "../auth"; +import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form"; + +// --------------------------------------------------------------------------- +// Cloud-only org-switcher slot for the shared shell's account dropdown. +// +// The shared `Shell` renders this `orgMenuSlot` ABOVE its API-keys link. Cloud +// is the only product with multiple organizations, so the switcher + create-org +// dialog live here and are injected, keeping the shared shell provider-neutral. +// The create-org dialog is controlled by local state so its `DialogContent` +// can live outside the dropdown menu while the trigger sits inside it. +// --------------------------------------------------------------------------- + +function CheckIcon() { + return ( + + + + ); +} + +function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) { + const organizations = useAtomValue(organizationsAtom); + const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" }); + + const handleSwitch = async (organizationId: string) => { + if (organizationId === props.activeOrganizationId) return; + const exit = await doSwitchOrganization({ + payload: { organizationId }, + reactivityKeys: authWriteKeys, + }); + if (Exit.isSuccess(exit)) window.location.reload(); + }; + + return AsyncResult.match(organizations, { + onInitial: () => Loading…, + onFailure: () => Failed to load organizations, + onSuccess: ({ value }) => + value.organizations.length === 0 ? ( + No organizations + ) : ( + <> + {value.organizations.map((organization: { id: string; name: string }) => { + const isActive = organization.id === props.activeOrganizationId; + return ( + handleSwitch(organization.id)} + className="text-xs" + > + {organization.name} + {isActive && } + + ); + })} + + ), + }); +} + +export function OrgMenuSlot() { + const auth = useAuth(); + const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false); + + const suggestedOrganizationName = + auth.status === "authenticated" && auth.user.name?.trim() !== "" && auth.user.name != null + ? `${auth.user.name}'s Organization` + : "New Organization"; + + const form = useCreateOrganizationForm({ + defaultName: suggestedOrganizationName, + onSuccess: () => window.location.reload(), + }); + + if (auth.status !== "authenticated") return null; + + const openCreateOrganization = () => { + form.reset(suggestedOrganizationName); + setCreateOrganizationOpen(true); + }; + + return ( + <> + + Organization + + + + + {auth.organization?.name ?? "No organization"} + + + + + + { + event.preventDefault(); + openCreateOrganization(); + }} + > + Create organization + + + + + + { + setCreateOrganizationOpen(open); + if (!open) form.reset(suggestedOrganizationName); + }} + > + + + Create organization + + Add another organization under your current account and switch into it immediately. + + + + { + form.setName(name); + if (form.error) form.setError(null); + }} + error={form.error} + onSubmit={() => void form.submit()} + /> + + + + + + + + + + + ); +} diff --git a/apps/cloud/src/web/components/support-slot.tsx b/apps/cloud/src/web/components/support-slot.tsx new file mode 100644 index 000000000..50f2ddf71 --- /dev/null +++ b/apps/cloud/src/web/components/support-slot.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { Button } from "@executor-js/react/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { SupportOptions } from "./support-options"; + +// --------------------------------------------------------------------------- +// Cloud-only "Get support" button for the shared shell's `supportSlot`. +// --------------------------------------------------------------------------- + +function HelpIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function SupportSlot() { + const [open, setOpen] = useState(false); + return ( + + + + + Get support + + Reach out through any of the channels below. + + +
+ +
+
+
+ ); +} diff --git a/apps/cloud/src/web/org-atoms.ts b/apps/cloud/src/web/org-atoms.ts index 8abf03068..3ae889a20 100644 --- a/apps/cloud/src/web/org-atoms.ts +++ b/apps/cloud/src/web/org-atoms.ts @@ -2,23 +2,10 @@ import * as Atom from "effect/unstable/reactivity/Atom"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { CloudApiClient } from "./client"; -export const orgMembersAtom = Atom.refreshOnWindowFocus( - CloudApiClient.query("org", "listMembers", { - timeToLive: "30 seconds", - reactivityKeys: [ReactivityKey.orgMembers], - }), -); - -export const orgRolesAtom = CloudApiClient.query("org", "listRoles", { - timeToLive: "5 minutes", - reactivityKeys: [ReactivityKey.orgMembers], -}); - -export const inviteMember = CloudApiClient.mutation("org", "invite"); - -export const removeMember = CloudApiClient.mutation("org", "removeMember"); - -export const updateMemberRole = CloudApiClient.mutation("org", "updateMemberRole"); +// Cloud-only WorkOS domain-verification atoms over the surviving `/org/domains` +// endpoints. Members / roles / invite / org-name now flow through the shared +// `@executor-js/react` account atoms (`/account/*`), so they no longer live +// here. export const orgDomainsAtom = Atom.refreshOnWindowFocus( CloudApiClient.query("org", "listDomains", { @@ -33,5 +20,3 @@ export const getDomainVerificationLink = CloudApiClient.mutation( ); export const deleteDomain = CloudApiClient.mutation("org", "deleteDomain"); - -export const updateOrgName = CloudApiClient.mutation("org", "updateOrgName"); diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index cd97ea869..0c3c2cf6a 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -1,569 +1,38 @@ -import { Link, Outlet, useLocation } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; -import { useAtomValue, useAtomSet } from "@effect/atom-react"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import * as Exit from "effect/Exit"; -import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; -import { useScope } from "@executor-js/react/api/scope-context"; -import { Button } from "@executor-js/react/components/button"; -import { Skeleton } from "@executor-js/react/components/skeleton"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@executor-js/react/components/dialog"; -import { SupportOptions } from "./components/support-options"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@executor-js/react/components/dropdown-menu"; -import { SourceFavicon, sourcePresetIconUrl } from "@executor-js/react/components/source-favicon"; -import { CommandPalette } from "@executor-js/react/components/command-palette"; -import { useSourcePlugins } from "@executor-js/sdk/client"; -import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; import { AUTH_PATHS } from "../auth/api"; -import { organizationsAtom, switchOrganization, useAuth } from "./auth"; -import { - CreateOrganizationFields, - useCreateOrganizationForm, -} from "./components/create-organization-form"; - -// ── Brand ──────────────────────────────────────────────────────────────── - -function Brand(props: { onNavigate?: () => void }) { - return ( - - executor - - Beta - - - ); -} - -// ── NavItem ────────────────────────────────────────────────────────────── - -function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { - return ( - - {props.label} - - ); -} - -// ── SourceList ─────────────────────────────────────────────────────────── - -function SourceList(props: { pathname: string; onNavigate?: () => void }) { - const scopeId = useScope(); - const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); - const sourcePlugins = useSourcePlugins(); - - return AsyncResult.match(sources, { - onInitial: () => ( -
- {[80, 65, 72, 58, 68].map((w, i) => ( -
- - -
- ))} -
- ), - onFailure: () => ( -
No sources yet
- ), - onSuccess: ({ value }) => - value.length === 0 ? ( -
- No sources yet -
- ) : ( -
- {value.map((s) => { - const detailPath = `/sources/${s.id}`; - const active = - props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); - return ( - - - {s.name} - - {s.kind} - - - ); - })} -
- ), - }); -} - -// ── UserFooter ────────────────────────────────────────────────────────── - -function initialsFor(name: string | null, email: string) { - if (name) { - return name - .split(" ") - .map((n) => n[0]) - .join("") - .slice(0, 2) - .toUpperCase(); - } - return email[0]!.toUpperCase(); -} - -function Avatar(props: { - url: string | null; - name: string | null; - email: string; - size?: "sm" | "md"; -}) { - const size = props.size === "md" ? "size-8" : "size-7"; - const text = props.size === "md" ? "text-sm" : "text-xs"; - if (props.url) { - return ; - } - return ( -
- {initialsFor(props.name, props.email)} -
- ); -} - -function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) { - const organizations = useAtomValue(organizationsAtom); - const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" }); - - const handleSwitch = async (organizationId: string) => { - if (organizationId === props.activeOrganizationId) return; - const exit = await doSwitchOrganization({ - payload: { organizationId }, - reactivityKeys: authWriteKeys, - }); - if (Exit.isSuccess(exit)) window.location.reload(); - }; - - return AsyncResult.match(organizations, { - onInitial: () => Loading…, - onFailure: () => Failed to load organizations, - onSuccess: ({ value }) => - value.organizations.length === 0 ? ( - No organizations - ) : ( - <> - {value.organizations.map((organization: { id: string; name: string }) => { - const isActive = organization.id === props.activeOrganizationId; - return ( - handleSwitch(organization.id)} - className="text-xs" - > - {organization.name} - {isActive && } - - ); - })} - - ), - }); -} - -function CheckIcon() { - return ( - - - - ); -} - -function UserFooter() { - const auth = useAuth(); - const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false); - - const suggestedOrganizationName = - auth.status === "authenticated" && auth.user.name?.trim() !== "" && auth.user.name != null - ? `${auth.user.name}'s Organization` - : "New Organization"; - - const form = useCreateOrganizationForm({ - defaultName: suggestedOrganizationName, - onSuccess: () => window.location.reload(), - }); - - if (auth.status !== "authenticated") return null; - - const openCreateOrganization = () => { - form.reset(suggestedOrganizationName); - setCreateOrganizationOpen(true); - }; - - return ( -
- { - setCreateOrganizationOpen(open); - if (!open) form.reset(suggestedOrganizationName); - }} - > - - - - - - - Organization - - - - - {auth.organization?.name ?? "No organization"} - - - - - - { - event.preventDefault(); - openCreateOrganization(); - }} - > - Create organization - - - - - - API keys - - - - Signed in as - - - -
-

- {auth.user.name ?? auth.user.email} -

- {auth.user.name && ( -

{auth.user.email}

- )} -
-
- { - await fetch(AUTH_PATHS.logout, { method: "POST" }); - window.location.href = "/"; - }} - > - Sign out - -
-
- - - - Create organization - - Add another organization under your current account and switch into it immediately. - - - - { - form.setName(name); - if (form.error) form.setError(null); - }} - error={form.error} - onSubmit={() => void form.submit()} - /> - - - - - - - - -
-
- ); -} - -// ── SupportButton ──────────────────────────────────────────────────────── - -function HelpIcon({ className }: { className?: string }) { - return ( - - - - - ); -} - -function SupportButton() { - const [open, setOpen] = useState(false); - return ( - - - - - Get support - - Reach out through any of the channels below. - - -
- -
-
-
- ); -} - -// ── SidebarContent ─────────────────────────────────────────────────────── - -function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { - const isHome = props.pathname === "/"; - const isSecrets = props.pathname === "/secrets"; - const isConnections = props.pathname === "/connections"; - const isPolicies = props.pathname === "/policies"; - const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); - const isOrg = props.pathname === "/org"; - - return ( - <> - {props.showBrand !== false && ( -
- -
- )} - - - -
- -
- - - - ); -} - -// ── Shell ───────────────────────────────────────────────────────────────── +import { OrgMenuSlot } from "./components/org-menu-slot"; +import { SupportSlot } from "./components/support-slot"; + +// --------------------------------------------------------------------------- +// Cloud shell — the SHARED multiplayer shell, identical to self-host, with +// cloud-only bits injected through its slots: +// - sign-out POST cloud's WorkOS logout, then redirect home +// - nav items defaults + Organization + Billing (cloud-only sections) +// - org menu slot multi-org switcher + create-org dialog (cloud-only) +// - support slot the "Get support" dialog button (cloud-only) +// The shared shell already renders the account dropdown frame, API-keys link, +// and sign-out; `orgMenuSlot` is injected above the API-keys link. +// --------------------------------------------------------------------------- + +const navItems = [ + ...defaultShellNavItems, + { to: "/org", label: "Organization" }, + { to: "/billing", label: "Billing" }, +]; + +const signOut = async () => { + await fetch(AUTH_PATHS.logout, { method: "POST" }); + window.location.href = "/"; +}; export function Shell() { - const location = useLocation(); - const pathname = location.pathname; - const lastPathname = useRef(pathname); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - if (lastPathname.current !== pathname) { - lastPathname.current = pathname; - if (mobileSidebarOpen) setMobileSidebarOpen(false); - } - - // Lock scroll when mobile sidebar open - useEffect(() => { - if (!mobileSidebarOpen) return; - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = prev; - }; - }, [mobileSidebarOpen]); - return ( -
- - {/* Desktop sidebar */} - - - {/* Mobile sidebar overlay */} - {mobileSidebarOpen && ( -
- {/* oxlint-disable-next-line react/forbid-elements */} - -
- setMobileSidebarOpen(false)} - showBrand={false} - /> -
- - )} - - {/* Main content */} -
- {/* Mobile top bar */} -
- - -
-
- - -
- + } + supportSlot={} + /> ); } diff --git a/apps/cloud/test-stubs/tanstack-start-entry.ts b/apps/cloud/test-stubs/tanstack-start-entry.ts new file mode 100644 index 000000000..84b94b1bb --- /dev/null +++ b/apps/cloud/test-stubs/tanstack-start-entry.ts @@ -0,0 +1,20 @@ +// Test-only stub for TanStack Start's `#tanstack-*` subpath-imports specifiers. +// +// `@tanstack/start-server-core` (reached transitively from +// `@tanstack/react-start/server`, which `auth/handlers.ts` uses for +// `setCookie`/`deleteCookie`) does `import("#tanstack-start-entry")` / +// `"#tanstack-router-entry"` / `"#tanstack-start-plugin-adapters"`. Those +// `imports`-field specifiers are declared on `@tanstack/start-client-core`, +// not on `start-server-core`, so Vite's resolver — used by the workerd vitest +// pool — can't find them relative to the importing package and errors at module +// load (`Missing "#tanstack-router-entry" specifier in "@tanstack/start-server-core"`). +// +// In real builds the app's bundler injects the user's generated entry here; in +// the SSR/handler code-path cloud never exercises (cloud only calls the cookie +// helpers), so the union of the fake-entry export surfaces is enough to let the +// module graph load. The vitest configs alias all three `#tanstack-*` +// specifiers to this single stub. +export const startInstance = undefined; +export function getRouter() {} +export const pluginSerializationAdapters: readonly unknown[] = []; +export const hasPluginAdapters = false; diff --git a/apps/cloud/vitest.config.ts b/apps/cloud/vitest.config.ts index 68be4ca21..bc71ac8e4 100644 --- a/apps/cloud/vitest.config.ts +++ b/apps/cloud/vitest.config.ts @@ -1,12 +1,33 @@ +import { resolve } from "node:path"; + import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; import { defineConfig } from "vitest/config"; +// `auth/handlers.ts` imports `setCookie`/`deleteCookie` from +// `@tanstack/react-start/server`. That barrel transitively pulls in +// `@tanstack/start-server-core`, which does `import("#tanstack-start-entry")` +// (+ `#tanstack-router-entry` / `#tanstack-start-plugin-adapters`). Those +// `imports`-field specifiers are declared on `@tanstack/start-client-core`, +// not on `start-server-core`, so Vite's resolver (used by the workerd pool) +// can't resolve them relative to the importing package and the module graph +// fails to load. Alias the three specifiers to a no-op stub so the workerd +// pool can load any module that transitively imports react-start. Cloud only +// uses the cookie helpers, never the SSR handler path the stub shims out. +const tanstackStartEntryStub = resolve(__dirname, "./test-stubs/tanstack-start-entry.ts"); + export default defineConfig({ plugins: [ cloudflareTest({ wrangler: { configPath: "./wrangler.test.jsonc" }, }), ], + resolve: { + alias: { + "#tanstack-start-entry": tanstackStartEntryStub, + "#tanstack-router-entry": tanstackStartEntryStub, + "#tanstack-start-plugin-adapters": tanstackStartEntryStub, + }, + }, test: { include: ["src/**/*.test.ts"], exclude: ["src/**/*.node.test.ts", "**/node_modules/**"], diff --git a/apps/cloud/wrangler.miniflare.jsonc b/apps/cloud/wrangler.miniflare.jsonc index 0221bfd59..4507d7e49 100644 --- a/apps/cloud/wrangler.miniflare.jsonc +++ b/apps/cloud/wrangler.miniflare.jsonc @@ -7,7 +7,7 @@ "name": "executor-cloud-miniflare", "compatibility_date": "2025-06-01", "compatibility_flags": ["nodejs_compat"], - "main": "src/test-worker.ts", + "main": "src/testing/test-worker.ts", "vars": { "DATABASE_URL": "postgresql://postgres:postgres@127.0.0.1:5434/postgres", "EXECUTOR_DIRECT_DATABASE_URL": "true", @@ -17,6 +17,7 @@ "MCP_AUTHKIT_DOMAIN": "https://test-authkit.example.com", "MCP_RESOURCE_ORIGIN": "https://test-resource.example.com", "NODE_ENV": "test", + "ALLOW_LOCAL_NETWORK": "true", }, "durable_objects": { "bindings": [{ "name": "MCP_SESSION", "class_name": "McpSessionDO" }], diff --git a/apps/cloud/wrangler.test.jsonc b/apps/cloud/wrangler.test.jsonc index 4009b6fa9..0c99b1f08 100644 --- a/apps/cloud/wrangler.test.jsonc +++ b/apps/cloud/wrangler.test.jsonc @@ -3,7 +3,7 @@ "name": "executor-cloud-test", "compatibility_date": "2025-06-01", "compatibility_flags": ["nodejs_compat"], - "main": "src/test-worker.ts", + "main": "src/testing/test-worker.ts", "vars": { "DATABASE_URL": "postgresql://postgres:postgres@127.0.0.1:5434/postgres", "EXECUTOR_DIRECT_DATABASE_URL": "true", @@ -13,6 +13,7 @@ "MCP_AUTHKIT_DOMAIN": "https://test-authkit.example.com", "MCP_RESOURCE_ORIGIN": "https://test-resource.example.com", "NODE_ENV": "test", + "ALLOW_LOCAL_NETWORK": "true", }, "durable_objects": { "bindings": [{ "name": "MCP_SESSION", "class_name": "McpSessionDO" }], diff --git a/apps/desktop/scripts/build-sidecar.ts b/apps/desktop/scripts/build-sidecar.ts index b167e508c..27fd460e9 100644 --- a/apps/desktop/scripts/build-sidecar.ts +++ b/apps/desktop/scripts/build-sidecar.ts @@ -23,7 +23,7 @@ const SIDECAR_ENTRY = resolve(ROOT, "src/sidecar/server.ts"); const SIDECAR_OUT_DIR = resolve(ROOT, "resources/sidecar"); const WEB_UI_OUT_DIR = resolve(ROOT, "resources/web-ui"); const APPS_LOCAL_DIST = resolve(APPS_LOCAL, "dist"); -const EMBEDDED_MIGRATIONS_PATH = resolve(APPS_LOCAL, "src/server/embedded-migrations.gen.ts"); +const EMBEDDED_MIGRATIONS_PATH = resolve(APPS_LOCAL, "src/db/embedded-migrations.gen.ts"); const EMBEDDED_MIGRATIONS_STUB = `const migrations: Record | null = null;\n\nexport default migrations;\n`; /** diff --git a/apps/host-cloudflare/.gitignore b/apps/host-cloudflare/.gitignore new file mode 100644 index 000000000..818180624 --- /dev/null +++ b/apps/host-cloudflare/.gitignore @@ -0,0 +1,3 @@ +dist/ +.dev.vars +.wrangler/ diff --git a/apps/host-cloudflare/CHANGELOG.md b/apps/host-cloudflare/CHANGELOG.md new file mode 100644 index 000000000..8f5f5c719 --- /dev/null +++ b/apps/host-cloudflare/CHANGELOG.md @@ -0,0 +1,6 @@ +# @executor-js/host-cloudflare changelog + +This file exists for `changesets/action@v1` compatibility (it reads every +workspace package's `CHANGELOG.md` to build the Version Packages PR). +Canonical user-facing release notes are at `apps/cli/release-notes/next.md` +and on the GitHub Releases page. diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md new file mode 100644 index 000000000..1770dbbe8 --- /dev/null +++ b/apps/host-cloudflare/README.md @@ -0,0 +1,89 @@ +# @executor-js/host-cloudflare + +Executor as a single Cloudflare Worker. The fourth app on the shared +`ExecutorApp.make` facade (alongside cloud, self-host, and local) — same code +paths, different injected providers: + +| Seam | Cloudflare provider | +| ------------ | ---------------------------------------------------------------- | +| **identity** | Cloudflare Access JWT (`Cf-Access-Jwt-Assertion`) — no app login | +| **db** | D1 (SQLite) via the shared FumaDB assembly | +| **engine** | QuickJS-WASM, in-Worker (no extra binding) | +| **mcp** | Access-JWT auth + the shared in-process session store | +| **account** | `/account/me` from the Access principal (members/keys → Access) | +| **web** | the shared multiplayer SPA (Workers Static Assets) | + +Single-tenant: every Access-verified principal belongs to the one configured +org. Members and credentials are managed in Cloudflare Access, not in-app. + +## Surfaces + +- `GET /` — the shared Executor web UI (Sources, Connections, Secrets, + Policies) — the same shell as cloud/self-host, built by `vite build` into + `dist/` and served via Workers Static Assets (`single-page-application` + fallback for client routes). +- `/api/*` — the full Executor API (scopes, sources, secrets, account, …). +- `/mcp` — streamable-HTTP MCP with an `execute` tool. + +`run_worker_first` in `wrangler.jsonc` keeps `/api/*` + `/mcp` on the Worker; +everything else is the SPA. Every API/MCP route is gated by the Access JWT (401 +without). The SPA's auth context reads `/api/account/me`. + +## Deploy + +```bash +bunx wrangler login +bun run deploy:setup # apps/host-cloudflare — provisions D1 + secret + deploys +``` + +`deploy:setup` (scripts/deploy.sh) is idempotent: it creates/reuses the +`executor` D1 database, writes its id into `wrangler.jsonc`, generates + +uploads `EXECUTOR_SECRET_KEY`, and deploys. It then prints the one manual step. + +### The one manual step — Cloudflare Access + +The Worker returns 401 until it's behind a Cloudflare Access application. In the +Zero Trust dashboard: + +1. **Access → Applications → Add an application → Self-hosted** +2. Application domain: `executor-cloudflare..workers.dev` +3. Add an Access policy (e.g. _Emails ending in `@yourcompany.com`_) +4. Copy the Application **Audience (AUD)** tag, then: + ```bash + bunx wrangler deploy \ + --var ACCESS_AUD: \ + --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com + ``` + (or set them in `wrangler.jsonc` and redeploy) + +Now visiting the Worker prompts an Access login; the Worker validates the issued +JWT on every request. MCP clients present an Access JWT or +`Cf-Access-Client-Id`/`-Secret` service-token headers. + +## Local development + +```bash +# .dev.vars +EXECUTOR_SECRET_KEY=dev-secret-key-0123456789abcdef +ENABLE_DEV_AUTH=true # bypass Access; every request is a fixed dev admin + +bun run build # vite build -> dist/ (the SPA) +bunx wrangler dev --local # serves the SPA + Worker API together +``` + +`bun run dev:web` runs the Vite dev server (HMR) for UI work; point its API at a +running `wrangler dev` if you need live data. + +`ENABLE_DEV_AUTH` is a dev-only escape hatch — never set it in a deployed +environment (it disables the Access gate). + +## Notes + +- The QuickJS engine WASM is vendored into `src/quickjs-engine.wasm` (Workers + forbid runtime WASM compilation; it must be statically imported). Refresh it + after bumping the engine with `bun run vendor-wasm`. +- MCP sessions live in-process (one isolate owns a session). The cross-isolate + upgrade is a Durable Object behind the same `McpSessionStore` seam. +- When Cloudflare's dynamic Worker Loader leaves closed beta, the QuickJS code + substrate swaps for the dynamic-worker executor behind the `engine` seam — a + one-Layer change. diff --git a/apps/host-cloudflare/executor.config.ts b/apps/host-cloudflare/executor.config.ts new file mode 100644 index 000000000..265bd5e79 --- /dev/null +++ b/apps/host-cloudflare/executor.config.ts @@ -0,0 +1,24 @@ +import { defineExecutorConfig } from "@executor-js/sdk"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; +import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; + +// --------------------------------------------------------------------------- +// Plugin list for the Cloudflare web build. The Vite `executorVitePlugin` reads +// this to assemble `virtual:executor/plugins-client` (the client-side plugin +// bundles the shell renders). It mirrors the runtime list in src/plugins.ts — +// same protocol/provider plugins as self-host. The encrypted-secrets key only +// matters at runtime (server side); a build-time placeholder is fine here since +// the client bundle never holds the key. +// --------------------------------------------------------------------------- + +export default defineExecutorConfig({ + plugins: () => + [ + openApiHttpPlugin(), + mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), + graphqlHttpPlugin(), + encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }), + ] as const, +}); diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json new file mode 100644 index 000000000..05f3981fd --- /dev/null +++ b/apps/host-cloudflare/package.json @@ -0,0 +1,57 @@ +{ + "name": "@executor-js/host-cloudflare", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "deploy": "vite build && wrangler deploy", + "dev": "wrangler dev", + "dev:web": "vite dev", + "typecheck": "tsgo --noEmit", + "cf-typegen": "wrangler types", + "deploy:setup": "bash scripts/deploy.sh", + "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@effect/atom-react": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/app": "workspace:*", + "@executor-js/cloudflare": "workspace:*", + "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-encrypted-secrets": "workspace:*", + "@executor-js/plugin-graphql": "workspace:*", + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/react": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@jitl/quickjs-wasmfile-release-sync": "catalog:", + "@modelcontextprotocol/sdk": "^1.29.0", + "@tanstack/react-router": "catalog:", + "drizzle-orm": "catalog:", + "effect": "catalog:", + "fumadb": "workspace:*", + "jose": "^5.9.6", + "quickjs-emscripten-core": "0.31.0", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250410.0", + "@effect/vitest": "catalog:", + "@executor-js/vite-plugin": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@tanstack/router-plugin": "^1.167.12", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:", + "wrangler": "^4.95.0" + } +} diff --git a/apps/host-cloudflare/scripts/deploy.sh b/apps/host-cloudflare/scripts/deploy.sh new file mode 100755 index 000000000..4d8a2857e --- /dev/null +++ b/apps/host-cloudflare/scripts/deploy.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# One-shot deploy for the Executor Cloudflare host. +# +# Provisions everything a fresh account needs and deploys the Worker: +# 1. verifies wrangler is logged in +# 2. creates (or reuses) the `executor` D1 database and writes its id into +# wrangler.jsonc +# 3. generates + uploads EXECUTOR_SECRET_KEY (the at-rest secret key) if unset +# 4. deploys the Worker +# 5. prints the single manual step: the Cloudflare Access application +# +# Idempotent — safe to re-run. Run from anywhere: +# bash apps/host-cloudflare/scripts/deploy.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +CONFIG="$APP_DIR/wrangler.jsonc" +cd "$APP_DIR" + +step() { printf '\n\033[1;36m==> %s\033[0m\n' "$1"; } +info() { printf ' %s\n' "$1"; } + +step "Checking wrangler login" +if ! bunx wrangler whoami >/dev/null 2>&1; then + info "Not logged in. Run: bunx wrangler login" + exit 1 +fi +info "Logged in." + +step "Provisioning D1 database 'executor'" +# `d1 create` is non-idempotent (errors if it exists), so list first. +EXISTING_ID="$(bunx wrangler d1 list --json 2>/dev/null \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const r=JSON.parse(s).find(d=>d.name==="executor");process.stdout.write(r?r.uuid:"")}catch{}})')" +if [ -n "$EXISTING_ID" ]; then + DB_ID="$EXISTING_ID" + info "Reusing existing database: $DB_ID" +else + CREATE_OUT="$(bunx wrangler d1 create executor 2>&1)" + DB_ID="$(printf '%s' "$CREATE_OUT" | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" + info "Created database: $DB_ID" +fi +[ -n "$DB_ID" ] || { echo "Failed to resolve D1 database id" >&2; exit 1; } + +step "Writing D1 id into wrangler.jsonc" +# Replace whatever database_id is present (placeholder or a prior id). +node -e ' + const fs=require("fs"),p=process.argv[1],id=process.argv[2]; + let t=fs.readFileSync(p,"utf8"); + t=t.replace(/("database_id":\s*")[^"]*(")/, `$1${id}$2`); + fs.writeFileSync(p,t); +' "$CONFIG" "$DB_ID" +info "wrangler.jsonc -> $DB_ID" + +step "Ensuring EXECUTOR_SECRET_KEY secret" +if bunx wrangler secret list 2>/dev/null | grep -q EXECUTOR_SECRET_KEY; then + info "Secret already set — leaving it." +else + SECRET="$(node -e 'console.log(require("node:crypto").randomBytes(32).toString("hex"))')" + printf '%s' "$SECRET" | bunx wrangler secret put EXECUTOR_SECRET_KEY >/dev/null + info "Generated + uploaded a fresh 32-byte key." +fi + +step "Building the web SPA" +bunx vite build + +step "Deploying Worker" +bunx wrangler deploy + +cat <<'NEXT' + +==> One manual step left: turn on Cloudflare Access (the auth layer) + + The Worker is deployed but every request returns 401 until you put it behind + a Cloudflare Access application. In the Zero Trust dashboard: + + 1. Access -> Applications -> Add an application -> Self-hosted + 2. Application domain: executor-cloudflare..workers.dev + 3. Add an Access policy (e.g. "Emails ending in @yourcompany.com") + 4. After saving, copy the Application Audience (AUD) tag, then set: + bunx wrangler deploy --var ACCESS_AUD: \ + --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com + (or edit the vars in wrangler.jsonc and redeploy) + + That's it — visiting the Worker URL now prompts a Cloudflare Access login, + and the Worker validates the issued JWT on every request. + +NEXT diff --git a/apps/host-cloudflare/scripts/vendor-quickjs-wasm.ts b/apps/host-cloudflare/scripts/vendor-quickjs-wasm.ts new file mode 100755 index 000000000..1518babf4 --- /dev/null +++ b/apps/host-cloudflare/scripts/vendor-quickjs-wasm.ts @@ -0,0 +1,18 @@ +// Vendors the QuickJS engine WASM into src/ so wrangler's CompiledWasm module +// rule (rooted at the app dir) can statically compile it at build time. Workers +// forbid runtime WASM compilation, and the rule's glob won't reach the +// monorepo-root node_modules, so the bytes must live inside this app. +// +// Re-run after bumping @jitl/quickjs-wasmfile-release-sync: +// bun run scripts/vendor-quickjs-wasm.ts +import { copyFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const source = require.resolve("@jitl/quickjs-wasmfile-release-sync/wasm"); +const dest = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "quickjs-engine.wasm"); + +copyFileSync(source, dest); +console.log(`vendored ${source} -> ${dest}`); diff --git a/apps/host-cloudflare/src/account/account-provider.ts b/apps/host-cloudflare/src/account/account-provider.ts new file mode 100644 index 000000000..6836cd077 --- /dev/null +++ b/apps/host-cloudflare/src/account/account-provider.ts @@ -0,0 +1,74 @@ +import { Effect, Layer } from "effect"; + +import { + AccountProvider, + accountProviderMiddlewareLayer, + type AccountHeaders, +} from "@executor-js/api/server"; +import { AccountError, AccountUnauthorized } from "@executor-js/api"; + +import { makeAccessVerifier } from "../auth/cloudflare-access"; +import type { CloudflareConfig } from "../config"; + +// --------------------------------------------------------------------------- +// Cloudflare AccountProvider — backs the shared `/account/*` surface the +// multiplayer shell reads. Cloudflare Access is the identity, so `me` just +// reflects the Access principal (the same `makeAccessVerifier` the API gate +// uses), reading the `Cf-Access-Jwt-Assertion` header off the request. +// +// Single-tenant + Access-managed: members, roles, and API keys live in +// Cloudflare Access, NOT in the app. The shell hides the API-keys footer and +// shows no members page, so those methods are never reached from the UI; they +// return empty (reads) or a clear "managed by Cloudflare Access" error (writes) +// to satisfy the provider shape. +// --------------------------------------------------------------------------- + +const NOT_IN_APP = "Managed by Cloudflare Access, not in the app."; + +export const cloudflareAccountProvider = ( + config: CloudflareConfig, +): Layer.Layer => { + const { verify } = makeAccessVerifier(config); + + // The provider gets raw headers; rebuild a minimal Request so `verify` can + // read the Access assertion header (and honor the dev-auth bypass). + const principalFrom = (headers: AccountHeaders) => + verify(new Request("https://internal.local/", { headers: new Headers(headers) })); + + const forbiddenWrite = Effect.fail(new AccountError({ message: NOT_IN_APP })); + + return Layer.succeed(AccountProvider)({ + me: (headers) => + principalFrom(headers).pipe( + Effect.flatMap((principal) => + principal + ? Effect.succeed({ + user: { + id: principal.accountId, + email: principal.email, + name: principal.name, + avatarUrl: principal.avatarUrl, + }, + organization: { + id: principal.organizationId, + name: principal.organizationName, + }, + }) + : Effect.fail(new AccountUnauthorized()), + ), + ), + listApiKeys: () => Effect.succeed({ apiKeys: [] }), + createApiKey: () => forbiddenWrite, + revokeApiKey: () => forbiddenWrite, + listMembers: () => Effect.succeed({ members: [] }), + listRoles: () => Effect.succeed({ roles: [] }), + inviteMember: () => forbiddenWrite, + removeMember: () => forbiddenWrite, + updateMemberRole: () => forbiddenWrite, + updateOrgName: () => forbiddenWrite, + }); +}; + +/** The per-request `AccountProvider` middleware (mounted under `/api`). */ +export const cloudflareAccountMiddleware = (config: CloudflareConfig) => + accountProviderMiddlewareLayer(cloudflareAccountProvider(config)); diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts new file mode 100644 index 000000000..b664e8a32 --- /dev/null +++ b/apps/host-cloudflare/src/app.ts @@ -0,0 +1,74 @@ +import { Effect } from "effect"; + +import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; + +import { loadConfig, type CloudflareEnv } from "./config"; +import { makeCloudflarePlugins } from "./plugins"; +import { createD1ExecutorDb } from "./db/d1"; +import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access"; +import { + CloudflareCodeExecutorProvider, + makeCloudflareHostConfig, + makeCloudflarePluginsProvider, +} from "./execution"; +import { ErrorCaptureLive } from "./observability"; +import { cloudflareAccountMiddleware } from "./account/account-provider"; +import { makeCloudflareMcpSeams } from "./mcp"; +import { preloadQuickJs } from "./quickjs"; + +// =========================================================================== +// The Cloudflare host, as ONE `ExecutorApp.make` call — the 4th app alongside +// cloud / self-host / local, differing only by the injected Layers. +// +// The whole scenario in 60 seconds: Cloudflare Access is the identity (validate +// the Cf-Access-Jwt-Assertion JWT — no Better Auth, no WorkOS, no app login), +// D1 is the SQLite store (same FumaDB assembly as self-host), QuickJS is the +// in-process code substrate, no billing, single-tenant. `diff` against +// host-selfhost/src/app.ts is three injected Layers: identity, db, plugins/config. +// +// Built per isolate (async) so the D1 schema bring-up happens once at first +// fetch; `env` arrives with that fetch (a Worker has no module-scope bindings), +// so the providers close over it instead of reading process.env. +// =========================================================================== + +export const makeCloudflareApp = async (env: CloudflareEnv) => { + const config = loadConfig(env); + const plugins = makeCloudflarePlugins(config.secretKey); + + // Load the Workers-compatible (WASM-inlined) QuickJS variant before any + // executor is built — the default variant can't fetch its .wasm on Workers. + await preloadQuickJs(); + + // Open + idempotently bring up the D1 schema once (the long-lived handle the + // per-request scoped executor reads through the DbProvider seam). + const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS); + const identityLayer = cloudflareAccessIdentityLayer(config); + // MCP runs through the `MCP_SESSION` Durable Object (cross-isolate sessions); + // each session DO opens its own D1 handle, so it takes `env`, not `dbHandle`. + const mcp = makeCloudflareMcpSeams(config, env); + + const { appLayer, toWebHandler } = ExecutorApp.make({ + plugins, + providers: { + identity: identityLayer, + db: dbProviderLayer(Effect.succeed(dbHandle)), + engine: { codeExecutor: CloudflareCodeExecutorProvider }, // decorator defaults to no-op + plugins: { + provider: makeCloudflarePluginsProvider(config), + config: makeCloudflareHostConfig(config), + }, + errorCapture: ErrorCaptureLive, + // The account API (`/api/account/*`) backs the shared multiplayer shell's + // auth context; `me` reflects the Access principal. Members/keys are + // Access-managed, so the rest of the surface is stubbed. + account: cloudflareAccountMiddleware(config), + // The MCP serving envelope: Access-JWT auth + the shared in-process session + // store over the QuickJS engine. + mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, + }, + config: { mountPrefix: "/api", failure: textFailureStrategy }, + boot: identityLayer, + }); + + return { appLayer, toWebHandler }; +}; diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts new file mode 100644 index 000000000..0fa0b97ce --- /dev/null +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "@effect/vitest"; + +import type { CloudflareConfig } from "../config"; +import { principalFromAccessClaims } from "./cloudflare-access"; + +const config: CloudflareConfig = { + accessTeamDomain: "team.cloudflareaccess.com", + accessAud: "aud-tag", + accessNameClaim: "name", + accessGroupsClaim: "groups", + adminEmails: ["admin@example.com"], + organizationId: "default", + organizationName: "Default", + secretKey: "x".repeat(32), + allowLocalNetwork: false, + webBaseUrl: "https://localhost", + enableDevAuth: false, +}; + +describe("principalFromAccessClaims", () => { + it("maps a human identity (email + sub + groups)", () => { + const p = principalFromAccessClaims( + { sub: "user-123", email: "person@example.com", name: "Person", groups: ["eng"] }, + config, + ); + expect(p.accountId).toBe("user-123"); + expect(p.email).toBe("person@example.com"); + expect(p.name).toBe("Person"); + expect(p.roles).toEqual(["eng"]); + expect(p.organizationId).toBe("default"); + }); + + it("grants admin when the email is in the allowlist", () => { + const p = principalFromAccessClaims({ sub: "u", email: "ADMIN@example.com" }, config); + expect(p.roles).toContain("admin"); + }); + + it("gives a SERVICE TOKEN (common_name, no email/sub) a stable identity", () => { + // Cloudflare Access service-token JWT: common_name set, email/sub absent. + const p = principalFromAccessClaims({ common_name: "df8a20db.access", type: "app" }, config); + expect(p.accountId).toBe("df8a20db.access"); // not empty — stable per token + expect(p.name).toBe("df8a20db.access"); + expect(p.email).toBe(""); + expect(p.roles).toEqual(["member"]); // a token is a member, not an admin + expect(p.organizationId).toBe("default"); + }); + + it("defaults to member when there are no groups and no admin match", () => { + const p = principalFromAccessClaims({ sub: "u", email: "nobody@other.com" }, config); + expect(p.roles).toEqual(["member"]); + }); +}); diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts new file mode 100644 index 000000000..84ecc7845 --- /dev/null +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -0,0 +1,109 @@ +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { Effect, Layer } from "effect"; + +import { IdentityProvider, Unauthorized, type Principal } from "@executor-js/api/server"; + +import type { CloudflareConfig } from "../config"; + +// --------------------------------------------------------------------------- +// Cloudflare Access IdentityProvider — the CF-native swap for self-host's +// Better Auth. Cloudflare Access (Zero Trust) sits IN FRONT of the Worker and +// authenticates the human; it forwards a signed `Cf-Access-Jwt-Assertion` JWT. +// This provider verifies that JWT against the team's public JWKS and maps its +// claims onto the neutral `Principal`. There is no app-level login, no session +// store, no password — the IdP is the gate. +// +// Single-tenant: every verified principal belongs to the one configured org. +// Roles come from the admin allowlist + the Access groups claim. +// --------------------------------------------------------------------------- + +/** + * Map verified Access JWT claims onto the neutral `Principal`. Pure (no JWT + * verification) so it is unit-testable. Handles both human identities (email + + * sub, optional groups) and SERVICE TOKENS — machine/API-key auth via the + * `CF-Access-Client-Id`/`-Secret` headers — which carry `common_name` (the + * token's client id) instead of email/sub. Single-tenant: every principal + * belongs to the one configured org; admin comes from the email allowlist. + */ +export const principalFromAccessClaims = ( + claims: Record, + config: CloudflareConfig, +): Principal => { + const email = typeof claims.email === "string" ? claims.email : ""; + const sub = typeof claims.sub === "string" && claims.sub.length > 0 ? claims.sub : ""; + const commonName = typeof claims.common_name === "string" ? claims.common_name : ""; + const nameClaim = claims[config.accessNameClaim]; + const groupsClaim = claims[config.accessGroupsClaim]; + const groups = Array.isArray(groupsClaim) ? groupsClaim.map(String) : []; + const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase()); + + return { + accountId: sub || email || commonName, + organizationId: config.organizationId, + organizationName: config.organizationName, + email, + name: typeof nameClaim === "string" ? nameClaim : commonName || null, + avatarUrl: null, + roles: isAdmin ? ["admin", ...groups] : groups.length > 0 ? groups : ["member"], + }; +}; + +/** + * Resolve a request to its verified `Principal`, or `null` when the Access + * assertion is missing/invalid. The single source of truth for "who is this + * request", shared by the `IdentityProvider` (the API gate) and the MCP auth + * provider (the `/mcp` gate) so both enforce Access identically. + * + * `jose` caches + rotates the team JWKS, so build the verifier once per config. + */ +export const makeAccessVerifier = (config: CloudflareConfig) => { + const issuer = `https://${config.accessTeamDomain}`; + // Cached, lazily-fetched team signing keys; jose handles rotation + caching. + const jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); + + // Dev/single-user escape hatch: bypass Access entirely, every request is a + // fixed admin. Only when explicitly enabled (and the instance is otherwise + // unprotected). Mirrors the local app's single-user model. + const devPrincipal: Principal = { + accountId: "dev", + organizationId: config.organizationId, + organizationName: config.organizationName, + email: config.adminEmails[0] ?? "dev@local", + name: "Dev", + avatarUrl: null, + roles: ["admin"], + }; + + const verify = (request: Request): Effect.Effect => + Effect.gen(function* () { + if (config.enableDevAuth) return devPrincipal; + const token = request.headers.get("Cf-Access-Jwt-Assertion"); + if (!token) return null; + + const verified = yield* Effect.tryPromise({ + try: () => jwtVerify(token, jwks, { issuer, audience: config.accessAud }), + catch: () => "invalid access assertion", + }).pipe(Effect.orElseSucceed(() => null)); + if (!verified) return null; + + return principalFromAccessClaims(verified.payload as Record, config); + }); + + return { verify }; +}; + +export const cloudflareAccessIdentityLayer = ( + config: CloudflareConfig, +): Layer.Layer => { + const { verify } = makeAccessVerifier(config); + return Layer.succeed(IdentityProvider)( + IdentityProvider.of({ + authenticate: (request) => + verify(request).pipe( + Effect.flatMap((principal) => + principal ? Effect.succeed(principal) : Effect.fail(new Unauthorized()), + ), + ), + }), + ); +}; diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts new file mode 100644 index 000000000..ffb04847c --- /dev/null +++ b/apps/host-cloudflare/src/config.ts @@ -0,0 +1,94 @@ +import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types"; + +// --------------------------------------------------------------------------- +// Cloudflare host config. Unlike self-host (process.env + a data dir), a Worker +// receives its bindings + vars per request as `env`, so config is derived from +// that object — there is no process.env, no filesystem, no boot-time secret +// generation. Identity comes entirely from Cloudflare Access in front of the +// Worker; the only real secret is the at-rest secret-encryption key. +// --------------------------------------------------------------------------- + +export const CLOUDFLARE_NAMESPACE = "executor_cloudflare"; +export const CLOUDFLARE_SCHEMA_VERSION = "1.0.0"; + +export interface CloudflareEnv { + /** D1 database binding — the app's SQLite store. */ + readonly DB: D1Database; + /** R2 bucket binding — holds values too large for a D1 row (~1-2MB cap). */ + readonly BLOBS?: R2Bucket; + /** MCP session Durable Object namespace — one addressable isolate per MCP + * session (the DO id IS the session id), so a session survives across the + * Worker's stateless isolates. */ + readonly MCP_SESSION: DurableObjectNamespace; + /** Zero Trust team domain, e.g. `your-team.cloudflareaccess.com`. */ + readonly ACCESS_TEAM_DOMAIN: string; + /** The Access application's AUD tag (the JWT audience to verify). */ + readonly ACCESS_AUD: string; + /** Claim holding the display name (default `name`). */ + readonly ACCESS_NAME_CLAIM?: string; + /** Claim holding the user's groups (default `groups`). */ + readonly ACCESS_GROUPS_CLAIM?: string; + /** Comma-separated emails granted the admin role. */ + readonly ADMIN_EMAILS?: string; + /** The single organization id/name every authenticated user belongs to. */ + readonly SELF_HOSTED_ORG_ID?: string; + readonly SELF_HOSTED_ORG_NAME?: string; + /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */ + readonly EXECUTOR_SECRET_KEY?: string; + readonly ALLOW_LOCAL_NETWORK?: string; + readonly VITE_PUBLIC_SITE_URL?: string; + /** + * Dev/single-user escape hatch: when "true", skip Cloudflare Access entirely + * and treat every request as a fixed admin. For local `wrangler dev` and + * unattended validation only — NEVER set on a deployment that isn't already + * behind Access, or the instance is wide open. + */ + readonly ENABLE_DEV_AUTH?: string; +} + +export interface CloudflareConfig { + readonly accessTeamDomain: string; + readonly accessAud: string; + readonly accessNameClaim: string; + readonly accessGroupsClaim: string; + readonly adminEmails: readonly string[]; + readonly organizationId: string; + readonly organizationName: string; + readonly secretKey: string; + readonly allowLocalNetwork: boolean; + /** Explicit web base URL (`VITE_PUBLIC_SITE_URL`). Unset on a Worker with no + * static URL — the per-request origin is used instead (see RequestWebOrigin). */ + readonly webBaseUrl?: string; + readonly enableDevAuth: boolean; +} + +const splitLower = (value: string | undefined): readonly string[] => + (value ?? "") + .split(",") + .map((part) => part.trim().toLowerCase()) + .filter((part) => part.length > 0); + +export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { + const secretKey = env.EXECUTOR_SECRET_KEY?.trim(); + if (!secretKey || secretKey.length < 16) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the Worker must not boot without the at-rest secret key + throw new Error( + "EXECUTOR_SECRET_KEY must be set (wrangler secret put EXECUTOR_SECRET_KEY) — it encrypts stored secrets at rest in D1", + ); + } + return { + accessTeamDomain: env.ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/+$/, ""), + accessAud: env.ACCESS_AUD, + accessNameClaim: env.ACCESS_NAME_CLAIM ?? "name", + accessGroupsClaim: env.ACCESS_GROUPS_CLAIM ?? "groups", + adminEmails: splitLower(env.ADMIN_EMAILS), + organizationId: env.SELF_HOSTED_ORG_ID ?? "default", + organizationName: env.SELF_HOSTED_ORG_NAME ?? "Default", + secretKey, + allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", + // No static URL on a Worker — leave unset when VITE_PUBLIC_SITE_URL is absent + // and let the request origin drive it (RequestWebOrigin). Explicit still wins. + webBaseUrl: env.VITE_PUBLIC_SITE_URL, + enableDevAuth: env.ENABLE_DEV_AUTH === "true", + }; +}; diff --git a/apps/host-cloudflare/src/db/d1.ts b/apps/host-cloudflare/src/db/d1.ts new file mode 100644 index 000000000..558eb6487 --- /dev/null +++ b/apps/host-cloudflare/src/db/d1.ts @@ -0,0 +1,73 @@ +import { drizzle } from "drizzle-orm/d1"; +import { + createDrizzleRuntimeSchemaFromTables, + ensureDrizzleRuntimeSchemaFromTables, +} from "fumadb/adapters/drizzle"; +import type { D1Database, R2Bucket } from "@cloudflare/workers-types"; + +import { wrapD1WithR2Offload } from "./r2-blob-offload"; + +import { + collectTables, + createExecutorFumaDb, + type ExecutorDbHandle, +} from "@executor-js/api/server"; + +import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../config"; + +// --------------------------------------------------------------------------- +// D1 DbProvider handle — the CF-native swap for self-host's libSQL handle. +// +// D1 is SQLite, so this reuses the SAME shared FumaDB assembly self-host uses: +// build the runtime schema from the fixed executor table set, open drizzle over the D1 +// binding (drizzle-orm/d1), run the idempotent `ensureDrizzleRuntimeSchemaFrom- +// Tables` bring-up (generic CREATE TABLE IF NOT EXISTS over D1), and assemble +// `createExecutorFumaDb`. No driver to open (the binding is the connection), no +// PRAGMAs, no `close` teardown. +// --------------------------------------------------------------------------- + +export const createD1ExecutorDb = async ( + db: D1Database, + blobs: R2Bucket | undefined, +): Promise => { + const options = { + tables: collectTables(), + namespace: CLOUDFLARE_NAMESPACE, + version: CLOUDFLARE_SCHEMA_VERSION, + provider: "sqlite" as const, + }; + + // Offload oversized values to R2 (D1 caps a value at ~1-2MB). No-op for + // ordinary small rows; only multi-MB values (e.g. a large OpenAPI spec) leave + // D1. Without a bucket bound, fall back to plain D1 (small values only). + const connection = blobs ? wrapD1WithR2Offload(db, blobs) : db; + const schema = createDrizzleRuntimeSchemaFromTables(options); + const drizzleDb = drizzle(connection, { schema }); + + // D1 rejects SQL `BEGIN TRANSACTION` / `SAVEPOINT` (it requires the JS batch + // API), and the shared ensure wraps its DDL in a transaction when the handle + // exposes one. The bring-up is idempotent `CREATE TABLE IF NOT EXISTS`, so run + // it WITHOUT a transaction by handing the ensure a run-only view of the handle. + await ensureDrizzleRuntimeSchemaFromTables({ run: (query) => drizzleDb.run(query) }, options); + + // `interactiveTransactions: false` — D1 rejects interactive transactions, so + // the fuma adapter runs transaction callbacks directly (auto-commit per + // statement). Without this, every runtime write that wraps in a transaction + // (adding a source, etc.) emits `BEGIN` and 500s. libSQL keeps real + // transactions; D1 (same `provider: "sqlite"`) opts out here. + const { db: fumaDb, fuma } = createExecutorFumaDb(drizzleDb, { + ...options, + interactiveTransactions: false, + // D1 caps bound parameters at 100 per query; createMany batches to fit + // (otherwise a wide table like `tool` overflows with "too many SQL + // variables" when a source derives many tools). + maxBoundParameters: 100, + }); + + return { + db: fumaDb, + fuma, + // The D1 binding owns its own lifecycle; nothing to release. + close: async () => {}, + }; +}; diff --git a/apps/host-cloudflare/src/db/r2-blob-offload.test.ts b/apps/host-cloudflare/src/db/r2-blob-offload.test.ts new file mode 100644 index 000000000..fd4e55f09 --- /dev/null +++ b/apps/host-cloudflare/src/db/r2-blob-offload.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { D1Database, R2Bucket } from "@cloudflare/workers-types"; + +import { wrapD1WithR2Offload } from "./r2-blob-offload"; + +// --------------------------------------------------------------------------- +// Round-trip tests for the D1 -> R2 large-value offload. Minimal in-memory +// mocks for the D1 binding (captures the params actually bound; returns canned +// rows on read) and R2 (a Map). Exercises the public D1 surface the wrapper +// presents to drizzle: prepare -> bind -> run/all. +// --------------------------------------------------------------------------- + +const makeMemR2 = () => { + const store = new Map(); + const bucket = { + put: async (key: string, value: ArrayBuffer | ArrayBufferView | string) => { + const bytes = + typeof value === "string" + ? new TextEncoder().encode(value) + : value instanceof ArrayBuffer + ? new Uint8Array(value) + : new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + store.set(key, bytes); + }, + get: async (key: string) => { + const bytes = store.get(key); + if (!bytes) return null; + return { + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + text: async () => new TextDecoder().decode(bytes), + }; + }, + }; + // oxlint-disable-next-line executor/no-double-cast -- test mock: in-memory stand-in for the R2 binding + return { bucket: bucket as unknown as R2Bucket, store }; +}; + +// A fake D1 that records the params bound to the most recent statement and, on +// read, returns whatever rows the test stages. +const makeMockD1 = () => { + const state: { boundParams: unknown[]; rows: Record[] } = { + boundParams: [], + rows: [], + }; + const db = { + prepare: (_sql: string) => { + const stmt: Record = { + bind: (...params: unknown[]) => { + state.boundParams = params; + return stmt; + }, + run: async () => ({ success: true, meta: {}, results: state.rows }), + all: async () => ({ success: true, meta: {}, results: state.rows }), + first: async () => state.rows[0] ?? null, + raw: async () => state.rows.map((r) => Object.values(r)), + }; + return stmt; + }, + batch: async () => [], + exec: async () => ({ count: 0, duration: 0 }), + dump: async () => new ArrayBuffer(0), + }; + // oxlint-disable-next-line executor/no-double-cast -- test mock: in-memory stand-in for the D1 binding + return { db: db as unknown as D1Database, state }; +}; + +const big = "x".repeat(1_000_000); // > 800KB byte threshold + +describe("wrapD1WithR2Offload", () => { + it("offloads an oversized string param to R2 and binds a short pointer", async () => { + const r2 = makeMemR2(); + const mock = makeMockD1(); + const wrapped = wrapD1WithR2Offload(mock.db, r2.bucket); + + await wrapped.prepare("insert into t (a, b) values (?, ?)").bind("small", big).run(); + + expect(mock.state.boundParams[0]).toBe("small"); // small value untouched + const pointer = mock.state.boundParams[1]; + expect(typeof pointer).toBe("string"); + expect(pointer).not.toBe(big); + expect(String(pointer).length).toBeLessThan(200); // a short pointer, not 1MB + expect(r2.store.size).toBe(1); // exactly one blob written + }); + + it("leaves small params inline (no R2 write)", async () => { + const r2 = makeMemR2(); + const mock = makeMockD1(); + const wrapped = wrapD1WithR2Offload(mock.db, r2.bucket); + + await wrapped.prepare("insert into t (a) values (?)").bind("just small").run(); + + expect(mock.state.boundParams).toEqual(["just small"]); + expect(r2.store.size).toBe(0); + }); + + it("rehydrates a pointer back to the original value on read", async () => { + const r2 = makeMemR2(); + const mock = makeMockD1(); + const wrapped = wrapD1WithR2Offload(mock.db, r2.bucket); + + // Write to populate R2 + capture the pointer the column would store. + await wrapped.prepare("insert into t (b) values (?)").bind(big).run(); + const pointer = mock.state.boundParams[0]; + + // Now a read returns that pointer in the row; the wrapper must restore `big`. + mock.state.rows = [{ b: pointer }]; + const result = await wrapped.prepare("select b from t").all(); + + expect(result.results[0]!.b).toBe(big); + }); + + it("fails loud when an offloaded blob is missing (no silent corruption)", async () => { + const r2 = makeMemR2(); + const mock = makeMockD1(); + const wrapped = wrapD1WithR2Offload(mock.db, r2.bucket); + + // Write to mint a real pointer, then simulate R2 losing the object. + await wrapped.prepare("insert into t (b) values (?)").bind(big).run(); + const pointer = mock.state.boundParams[0]; + r2.store.clear(); + + mock.state.rows = [{ b: pointer }]; + await expect(wrapped.prepare("select b from t").all()).rejects.toThrow(/R2 blob lost/); + }); +}); diff --git a/apps/host-cloudflare/src/db/r2-blob-offload.ts b/apps/host-cloudflare/src/db/r2-blob-offload.ts new file mode 100644 index 000000000..262e0bffd --- /dev/null +++ b/apps/host-cloudflare/src/db/r2-blob-offload.ts @@ -0,0 +1,234 @@ +import type { + D1Database, + D1PreparedStatement, + D1Result, + R2Bucket, +} from "@cloudflare/workers-types"; + +// --------------------------------------------------------------------------- +// R2 large-value offload for D1 — transparent, plugin-agnostic, CF-host-only. +// +// D1 caps a single string/BLOB value at ~1-2MB (SQLITE_TOOBIG). Some plugin +// writes are much larger — e.g. the OpenAPI plugin inlines an entire resolved +// spec (Vercel's is ~7MB) into one `plugin_storage.data` row. This wraps the D1 +// binding so any bound parameter over a byte threshold is written to R2 and +// replaced in D1 with a tiny pointer string; on read, pointers are rehydrated +// back to the original value BEFORE drizzle/fumadb sees them (so the JSON-column +// decoder gets the real JSON, never the pointer). +// +// It sits at the D1 driver boundary (below drizzle), so it is column-agnostic +// and needs no schema knowledge: it only ever sees already-serialized string +// values. Keyed by content hash (idempotent + dedup). Deletes intentionally +// leave the R2 object (orphans are cheap; a sweeper is a future improvement). +// +// The proper long-term fix is plugin-level (store large specs via the executor +// `blobs`/BlobStore seam) — see the TODO in packages/plugins/openapi. +// --------------------------------------------------------------------------- + +// Astronomically-unlikely sentinel: a real value would have to be short AND +// start with this exact magic to be falsely rehydrated. +const POINTER_PREFIX = "\u0000__executor_r2_blob_v1__:"; + +// Offload values larger than this many UTF-8 bytes. Well under D1's ~1MB cap, +// and large enough that ordinary plugin rows (tiny) never touch R2. +const OFFLOAD_BYTE_THRESHOLD = 800_000; +// Cheap pre-filter: only measure byte length for strings longer than this many +// UTF-16 units (skips the millions of tiny params without a TextEncoder pass). +const LENGTH_PREFILTER = 200_000; + +const encoder = new TextEncoder(); + +const toHex = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let out = ""; + for (const b of bytes) out += b.toString(16).padStart(2, "0"); + return out; +}; + +const hashKey = async (bytes: Uint8Array): Promise => + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers vs DOM BufferSource type mismatch for crypto.subtle.digest + `blobs/${toHex(await crypto.subtle.digest("SHA-256", bytes as unknown as BufferSource))}`; + +// A bound param's storage form. fumadb encodes a `json` column to UTF-8 BYTES +// (bound as a D1 BLOB), so the oversized value is usually a Uint8Array — not a +// string or object. We offload by storage kind so the read can return the SAME +// shape D1 natively would (a BLOB cell -> ArrayBuffer, a TEXT cell -> string), +// keeping drizzle's column decoder happy. +type Offload = { kind: "b" | "t"; bytes: Uint8Array }; + +const oversizedOffload = (value: unknown): Offload | null => { + if (typeof value === "string") { + if (value.length <= LENGTH_PREFILTER) return null; + const bytes = encoder.encode(value); + return bytes.byteLength > OFFLOAD_BYTE_THRESHOLD ? { kind: "t", bytes } : null; + } + if (value instanceof ArrayBuffer) { + return value.byteLength > OFFLOAD_BYTE_THRESHOLD + ? { kind: "b", bytes: new Uint8Array(value) } + : null; + } + if (ArrayBuffer.isView(value)) { + return value.byteLength > OFFLOAD_BYTE_THRESHOLD + ? { kind: "b", bytes: new Uint8Array(value.buffer, value.byteOffset, value.byteLength) } + : null; + } + if (value !== null && typeof value === "object") { + const bytes = encoder.encode(JSON.stringify(value)); + return bytes.byteLength > OFFLOAD_BYTE_THRESHOLD ? { kind: "t", bytes } : null; + } + return null; +}; + +const isPointer = (value: unknown): value is string => + typeof value === "string" && value.startsWith(POINTER_PREFIX); + +// Minimal retry for transient R2 failures (network blips). 3 attempts with +// small exponential backoff; the final failure propagates with context so a +// real outage surfaces rather than corrupting a read/write. +const withRetry = async (op: () => Promise, what: string): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: retry transient R2 I/O before failing loud + try { + return await op(); + } catch (error) { + lastError = error; + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt)); + } + } + // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: D1/R2 driver wrapper (not Effect domain); surface the exhausted R2 failure with context + throw new Error(`Failed to ${what} after 3 attempts`, { cause: lastError }); +}; + +/** + * Write any oversized params to R2; replace each with a pointer string + * (`:`). The pointer is a short TEXT value D1 stores happily; + * the read path rehydrates it back to the original BLOB/TEXT shape before + * drizzle's column decoder runs. + */ +const offloadParams = (params: unknown[], bucket: R2Bucket): Promise => + Promise.all( + params.map(async (param) => { + const offload = oversizedOffload(param); + if (!offload) return param; + const key = await hashKey(offload.bytes); + await withRetry(() => bucket.put(key, offload.bytes), `offload value to R2 (${key})`); + return `${POINTER_PREFIX}${offload.kind}:${key}`; + }), + ); + +/** Resolve a single value: rehydrate from R2 if it is a pointer, else pass through. */ +const rehydrateValue = async (value: unknown, bucket: R2Bucket): Promise => { + if (!isPointer(value)) return value; + const rest = value.slice(POINTER_PREFIX.length); + const kind = rest[0]; + const key = rest.slice(2); // skip ":" + const object = await withRetry(() => bucket.get(key), `read offloaded value from R2 (${key})`); + // Fail LOUD on a lost blob — returning the raw pointer here would feed garbage + // into drizzle's column decoder and silently corrupt the read. + // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: D1/R2 driver wrapper (not Effect domain); R2 durability failure must surface, not silently pass the pointer through + if (!object) throw new Error(`R2 blob lost for offloaded D1 value: ${key}`); + // Return the SAME shape D1 would for that storage class: BLOB -> ArrayBuffer, + // TEXT -> string. (The json column is a BLOB, so fumadb gets bytes to decode.) + return kind === "b" ? await object.arrayBuffer() : await object.text(); +}; + +const rehydrateRow = async ( + row: Record, + bucket: R2Bucket, +): Promise> => { + let out: Record | null = null; + for (const [col, value] of Object.entries(row)) { + if (isPointer(value)) { + out ??= { ...row }; + out[col] = await rehydrateValue(value, bucket); + } + } + return out ?? row; +}; + +const rehydrateRows = ( + rows: T[] | undefined, + bucket: R2Bucket, +): Promise | T[] | undefined => { + if (!rows || rows.length === 0) return rows; + return Promise.all( + rows.map((row) => + row && typeof row === "object" + ? (rehydrateRow(row as Record, bucket) as Promise) + : Promise.resolve(row), + ), + ); +}; + +const rehydrateResult = async (result: T, bucket: R2Bucket): Promise => { + const rehydrated = await rehydrateRows(result.results as unknown[] | undefined, bucket); + return rehydrated === result.results ? result : { ...result, results: rehydrated }; +}; + +/** + * Wrap a `D1Database` so oversized bound values offload to R2 transparently. + * Returns an object satisfying the `D1Database` surface drizzle-d1 uses + * (`prepare` + the prepared-statement run/all/first/raw/bind methods, plus + * `batch`/`exec`/`dump` pass-through). Pure delegation otherwise. + */ +export const wrapD1WithR2Offload = (db: D1Database, bucket: R2Bucket): D1Database => { + const wrapStatement = ( + statement: D1PreparedStatement, + params: unknown[] | null, + ): D1PreparedStatement => { + // Bind happens synchronously in the D1 API, but offload is async — so we + // capture the params and only bind the (possibly offloaded) values when the + // terminal run/all/first/raw runs. + const bound = async (): Promise => + params ? statement.bind(...(await offloadParams(params, bucket))) : statement; + + const wrapped: D1PreparedStatement = { + bind: (...next: unknown[]) => wrapStatement(statement, next), + first: (async (colName?: string) => { + const s = await bound(); + const value = colName === undefined ? await s.first() : await s.first(colName); + if (value === null || value === undefined) return value; + if (colName !== undefined) return rehydrateValue(value, bucket); + return rehydrateRow(value as Record, bucket); + }) as D1PreparedStatement["first"], + run: (async () => + rehydrateResult(await (await bound()).run(), bucket)) as D1PreparedStatement["run"], + all: (async () => + rehydrateResult(await (await bound()).all(), bucket)) as D1PreparedStatement["all"], + raw: (async (options?: { columnNames?: boolean }) => { + // Call `.raw()` directly on the statement (do NOT extract the method — + // the D1 runtime reads `this.statement`, so a detached call throws). + // oxlint-disable-next-line executor/no-double-cast -- boundary: collapse D1 raw() overloads to one callable shape + const s = (await bound()) as unknown as { + raw(o?: { columnNames?: boolean }): Promise; + }; + const rows = await s.raw(options); + // `raw()` returns arrays of column values (optionally a header row of + // column names first); rehydrate the value cells, leave any header row. + return Promise.all( + rows.map((row, index) => + Array.isArray(row) && !(options?.columnNames && index === 0) + ? Promise.all(row.map((cell) => rehydrateValue(cell, bucket))) + : Promise.resolve(row), + ), + ); + }) as D1PreparedStatement["raw"], + }; + return wrapped; + }; + + // Intercept only `prepare` (to wrap statements); delegate everything else + // (batch/exec/dump/withSession + any internal fields) to the real binding, + // binding methods so D1's internal `this` stays intact. A Proxy preserves the + // D1Database type without enumerating-and-casting the surface. + return new Proxy(db, { + get(target, prop, receiver) { + if (prop === "prepare") { + return (query: string) => wrapStatement(target.prepare(query), null); + } + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +}; diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts new file mode 100644 index 000000000..2b76732ba --- /dev/null +++ b/apps/host-cloudflare/src/execution.ts @@ -0,0 +1,69 @@ +import { Effect, Layer } from "effect"; + +import { + CodeExecutorProvider, + DbProvider, + dbProviderLayer, + EngineDecorator, + EngineDecoratorNoop, + HostConfig, + PluginsProvider, + type ExecutorDbHandle, +} from "@executor-js/api/server"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; + +import type { CloudflareConfig } from "./config"; +import { makeCloudflarePlugins } from "./plugins"; + +// --------------------------------------------------------------------------- +// Cloudflare execution-stack seams — the same shape as self-host (QuickJS code +// substrate, no-op engine decorator), with the plugins + host config built from +// the per-request `env`-derived config rather than process.env. +// +// QuickJS-wasm is the default code substrate because it runs in a single Worker +// with no extra binding. When Cloudflare's dynamic Worker Loader leaves closed +// beta, swap CodeExecutorProvider for the dynamic-worker executor (cloud's) — +// it's a one-Layer change behind this same seam. +// --------------------------------------------------------------------------- + +export { makeExecutionStack } from "@executor-js/api/server"; +export { EngineDecoratorNoop }; + +export const CloudflareCodeExecutorProvider: Layer.Layer = Layer.sync( + CodeExecutorProvider, + () => makeQuickJsExecutor(), +); + +export const makeCloudflarePluginsProvider = ( + config: CloudflareConfig, +): Layer.Layer => + Layer.succeed(PluginsProvider)({ + plugins: () => makeCloudflarePlugins(config.secretKey), + }); + +export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer => + Layer.succeed(HostConfig)({ + allowLocalNetwork: config.allowLocalNetwork, + webBaseUrl: config.webBaseUrl, + }); + +/** + * The five execution-stack seams the shared `makeExecutionStack` reads from, + * bundled into one Layer over the long-lived D1 handle. Mirrors self-host's + * `SelfHostExecutionStackLayer`. The HTTP path wires these seams individually + * through `ExecutorApp.make`; the MCP session store provides this whole Layer to + * build a per-session engine off the envelope's request pipeline. + */ +export const makeCloudflareExecutionStackLayer = ( + config: CloudflareConfig, + dbHandle: ExecutorDbHandle, +): Layer.Layer< + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator +> => + Layer.mergeAll( + dbProviderLayer(Effect.succeed(dbHandle)), + makeCloudflarePluginsProvider(config), + makeCloudflareHostConfig(config), + CloudflareCodeExecutorProvider, + EngineDecoratorNoop, + ); diff --git a/apps/host-cloudflare/src/mcp/auth.ts b/apps/host-cloudflare/src/mcp/auth.ts new file mode 100644 index 000000000..8a55033a7 --- /dev/null +++ b/apps/host-cloudflare/src/mcp/auth.ts @@ -0,0 +1,35 @@ +import { Effect, Layer } from "effect"; + +import { authenticated, McpAuthProvider, unauthorized } from "@executor-js/host-mcp"; + +import { makeAccessVerifier } from "../auth/cloudflare-access"; +import type { CloudflareConfig } from "../config"; + +// --------------------------------------------------------------------------- +// Cloudflare Access McpAuthProvider — the `/mcp` gate, identical identity to the +// API gate. Cloudflare Access sits in front of the Worker and forwards the +// signed `Cf-Access-Jwt-Assertion` on every request, including `/mcp`. So the +// MCP auth seam reuses the SAME `makeAccessVerifier` the IdentityProvider uses: +// validate the JWT, map claims onto the neutral `Principal`, done. +// +// There is no MCP OAuth here. Auth is Access's browser/service-token flow, not +// the MCP `/authorize`+`/token` dance — so `discoveryRoutes` is empty and the +// 401 challenge points at a nominal protected-resource URL only to satisfy +// clients that probe for it. An external MCP client authenticates by presenting +// an Access JWT (or `Cf-Access-Client-Id`/`-Secret` service-token headers, which +// Access converts to one). When MCP OAuth-over-Access is needed, add the +// discovery docs + a token endpoint here behind this same seam. +// --------------------------------------------------------------------------- + +export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer => { + const { verify } = makeAccessVerifier(config); + return Layer.succeed(McpAuthProvider)({ + discoveryRoutes: [], + resourceMetadataUrl: (request) => + new URL("/.well-known/oauth-protected-resource", new URL(request.url).origin).toString(), + authenticate: (request) => + verify(request).pipe( + Effect.map((principal) => (principal ? authenticated(principal) : unauthorized())), + ), + }); +}; diff --git a/apps/host-cloudflare/src/mcp/index.ts b/apps/host-cloudflare/src/mcp/index.ts new file mode 100644 index 000000000..3b4f31156 --- /dev/null +++ b/apps/host-cloudflare/src/mcp/index.ts @@ -0,0 +1,49 @@ +import type { Layer } from "effect"; + +import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; + +import type { CloudflareConfig, CloudflareEnv } from "../config"; +import { cloudflareAccessMcpAuth } from "./auth"; +import { cloudflareMcpReporter, makeCloudflareMcpSessionStore } from "./session-store"; + +export { cloudflareAccessMcpAuth } from "./auth"; +export { cloudflareMcpReporter, makeCloudflareMcpSessionStore } from "./session-store"; +export { McpSessionDO } from "./session-durable-object"; + +// --------------------------------------------------------------------------- +// The Cloudflare MCP serving seams, fed to `ExecutorApp.make`'s `mcp` group. +// +// `ExecutorApp.make` mounts the shared, provider-neutral MCP serving envelope +// (@executor-js/host-mcp) at the top-level `/mcp`, outside the API's execution +// middleware. The Cloudflare host provides the two envelope seams plus the +// error-reporter override: +// - McpAuthProvider -> `cloudflareAccessMcpAuth`: validate the Access JWT +// (same identity as the API gate); no MCP OAuth. +// - McpSessionStore -> the shared Durable-Object dispatcher over the host's +// `MCP_SESSION` namespace (cross-isolate, same as cloud). +// - McpErrorReporter -> `cloudflareMcpReporter`: route 500 defects through the +// host's console capture. +// --------------------------------------------------------------------------- + +export interface CloudflareMcpSeams { + /** Validate the Access JWT to an MCP `AuthOutcome`; declares no discovery routes. */ + readonly auth: Layer.Layer; + /** The Durable-Object session store seam (dispatch + lifetime). */ + readonly sessions: Layer.Layer; + /** Route 500 defects through the host's console `ErrorCapture`. */ + readonly reporter: Layer.Layer; +} + +/** + * Build the Cloudflare MCP serving seams over the host's `MCP_SESSION` Durable + * Object namespace. No per-session DB handle is threaded here — each session DO + * opens its own D1 handle in its own isolate. + */ +export const makeCloudflareMcpSeams = ( + config: CloudflareConfig, + env: CloudflareEnv, +): CloudflareMcpSeams => ({ + auth: cloudflareAccessMcpAuth(config), + sessions: makeCloudflareMcpSessionStore(env), + reporter: cloudflareMcpReporter, +}); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts new file mode 100644 index 000000000..258e70c41 --- /dev/null +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -0,0 +1,88 @@ +import { Effect } from "effect"; + +import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import type { ExecutorDbHandle } from "@executor-js/api/server"; +import { + McpSessionDOBase, + type BuiltMcpServer, + type McpSessionInit, + type SessionMeta, +} from "@executor-js/cloudflare/mcp/durable-object"; + +import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; +import { createD1ExecutorDb } from "../db/d1"; +import { makeCloudflareExecutionStackLayer, makeExecutionStack } from "../execution"; +import { preloadQuickJs } from "../quickjs"; + +// --------------------------------------------------------------------------- +// Cloudflare (self-host) MCP Session Durable Object — the host-cloudflare +// binding of the shared `McpSessionDOBase` (@executor-js/cloudflare). Identical +// base to cloud; the ONLY differences are the injected dependencies: +// - openSessionDb → a long-lived D1 `ExecutorDbHandle` (same FumaDB +// assembly the HTTP path uses), adapted to the base's +// `end` disposal contract. +// - resolveSessionMeta → single-tenant: the org is fixed in config, so no +// lookup — just stamp the configured org name. +// - buildMcpServer → the QuickJS execution stack + the MCP tool server. +// host-cf has no OTel/Sentry, so it keeps the base's default no-op telemetry + +// error seams. Replacing the prior in-memory store with this DO is what fixes +// `tools/list` failing across Worker isolates (a session created on one isolate +// was invisible to the next; the DO id == session id routes them all back). +// --------------------------------------------------------------------------- + +// The long-lived D1 handle, adapted to the base's `end` contract. D1 owns its +// own lifecycle (the binding is the connection), so `end` is `close` — a no-op. +type CfSessionDbHandle = ExecutorDbHandle & { readonly end: () => Promise }; + +export class McpSessionDO extends McpSessionDOBase { + private readonly cfEnv: CloudflareEnv; + private readonly cfConfig: CloudflareConfig; + + // `ctx`'s type is taken from the base constructor so it tracks whichever + // `@cloudflare/workers-types` the shared package resolves (avoids a + // cross-version `DurableObjectState` mismatch at the `super` call). + constructor(ctx: ConstructorParameters[0], env: CloudflareEnv) { + super(ctx, env); + this.cfEnv = env; + this.cfConfig = loadConfig(env); + } + + protected override async openSessionDb(): Promise { + const handle = await createD1ExecutorDb(this.cfEnv.DB, this.cfEnv.BLOBS); + return { ...handle, end: () => handle.close() }; + } + + protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect { + // Single-tenant: every Access principal belongs to the one configured org, + // so there is nothing to resolve — stamp the configured org name. + return Effect.succeed({ + organizationId: token.organizationId, + organizationName: this.cfConfig.organizationName, + userId: token.userId, + elicitationMode: token.elicitationMode, + } satisfies SessionMeta); + } + + protected override buildMcpServer( + sessionMeta: SessionMeta, + dbHandle: CfSessionDbHandle, + ): Effect.Effect { + const config = this.cfConfig; + return Effect.gen(function* () { + // QuickJS-WASM must be loaded before the executor layer builds it (the + // default variant can't fetch its .wasm on Workers). Idempotent per isolate. + yield* Effect.promise(() => preloadQuickJs()); + const { engine } = yield* makeExecutionStack( + sessionMeta.userId, + sessionMeta.organizationId, + sessionMeta.organizationName, + ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); + const mcpServer = yield* createExecutorMcpServer({ engine }); + return { mcpServer, engine } satisfies BuiltMcpServer; + }).pipe( + Effect.withSpan("McpSessionDO.buildMcpServer"), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect + Effect.orDie, + ); + } +} diff --git a/apps/host-cloudflare/src/mcp/session-store.ts b/apps/host-cloudflare/src/mcp/session-store.ts new file mode 100644 index 000000000..cb4a8b114 --- /dev/null +++ b/apps/host-cloudflare/src/mcp/session-store.ts @@ -0,0 +1,43 @@ +import { Layer } from "effect"; + +import { makeConsoleMcpErrorReporter } from "@executor-js/api/server"; +import type { McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; +import { + makeDurableObjectMcpSessionStore, + type McpSessionDOStub, +} from "@executor-js/cloudflare/mcp/session-store"; + +import type { CloudflareEnv } from "../config"; +import { ErrorCaptureLive } from "../observability"; + +// --------------------------------------------------------------------------- +// Cloudflare McpSessionStore wiring — the SAME shared Durable-Object dispatcher +// as cloud (@executor-js/cloudflare), over host-cloudflare's `MCP_SESSION` +// namespace. The dispatch/identity/trace/peek logic all lives in the shared +// package; the host supplies ONLY its DO stub accessors (the session id IS the +// DO id, so every follow-up request routes back to the same isolate). +// +// This replaces the in-process store: an in-memory session map is invisible to +// the next Worker isolate, so `tools/list` after `initialize` failed in +// production ("Not connected"). The DO holds the session in one addressable +// isolate, fixing that across the board. +// --------------------------------------------------------------------------- + +// The DO RPC stub structurally satisfies `McpSessionDOStub` (init/handleRequest/ +// clearSession), but `@cloudflare/workers-types` types it as a generic +// `DurableObjectStub`. Narrow at this one boundary via an `unknown` hop — a +// single cast, so no double-cast through the worker-types stub type. +const toSessionStub = (stub: unknown): McpSessionDOStub => stub as McpSessionDOStub; + +/** Build the DO-backed MCP session store over the host's `MCP_SESSION` namespace. */ +export const makeCloudflareMcpSessionStore = (env: CloudflareEnv): Layer.Layer => + makeDurableObjectMcpSessionStore({ + getStub: (sessionId) => + toSessionStub(env.MCP_SESSION.get(env.MCP_SESSION.idFromString(sessionId))), + newStub: () => toSessionStub(env.MCP_SESSION.get(env.MCP_SESSION.newUniqueId())), + // host-cf has no Sentry; a 500-defect surfaces through the reporter seam below. + }); + +/** Route 500-defects through the host's console `ErrorCapture`. */ +export const cloudflareMcpReporter: Layer.Layer = + makeConsoleMcpErrorReporter(ErrorCaptureLive); diff --git a/apps/host-cloudflare/src/observability.ts b/apps/host-cloudflare/src/observability.ts new file mode 100644 index 000000000..249eb5206 --- /dev/null +++ b/apps/host-cloudflare/src/observability.ts @@ -0,0 +1,7 @@ +// Cloudflare host `ErrorCapture` — the shared console implementation with a +// `cloudflare-` trace-id prefix. Worker stdout is routed to Logpush/the +// dashboard, so the squashed cause is grep-able by the opaque 500 traceId. + +import { consoleErrorCapture } from "@executor-js/api/server"; + +export const ErrorCaptureLive = consoleErrorCapture("cloudflare"); diff --git a/apps/host-cloudflare/src/plugins.ts b/apps/host-cloudflare/src/plugins.ts new file mode 100644 index 000000000..941c80795 --- /dev/null +++ b/apps/host-cloudflare/src/plugins.ts @@ -0,0 +1,25 @@ +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; +import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; + +// --------------------------------------------------------------------------- +// The Cloudflare host's plugin list — the same protocol/provider plugins as +// self-host (no WorkOS Vault). Built as a factory because the encrypted-secrets +// master key arrives via `env` at request time (no process.env on a Worker), so +// the plugin set is constructed per app-build with the resolved key. The tuple +// SHAPE (which drives the API + table set) is independent of the key value. +// +// `dangerouslyAllowStdioMCP` is false: a multi-user instance must not let a user +// spawn arbitrary stdio MCP processes. +// --------------------------------------------------------------------------- + +export const makeCloudflarePlugins = (secretKey: string) => + [ + openApiHttpPlugin(), + mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), + graphqlHttpPlugin(), + encryptedSecretsPlugin({ key: secretKey }), + ] as const; + +export type CloudflarePlugins = ReturnType; diff --git a/apps/host-cloudflare/src/quickjs-engine.wasm b/apps/host-cloudflare/src/quickjs-engine.wasm new file mode 100644 index 000000000..ee1a98f5a Binary files /dev/null and b/apps/host-cloudflare/src/quickjs-engine.wasm differ diff --git a/apps/host-cloudflare/src/quickjs.ts b/apps/host-cloudflare/src/quickjs.ts new file mode 100644 index 000000000..9d84484ed --- /dev/null +++ b/apps/host-cloudflare/src/quickjs.ts @@ -0,0 +1,35 @@ +import { newQuickJSWASMModuleFromVariant, newVariant } from "quickjs-emscripten-core"; +import baseVariant from "@jitl/quickjs-wasmfile-release-sync"; +// Static .wasm import: wrangler/workerd compiles this to a WebAssembly.Module at +// BUILD time. Workers forbid runtime WASM compilation (both fetching the .wasm +// and `WebAssembly.instantiate()` of bytes are blocked), so the engine bytes +// MUST be a pre-compiled module imported like this. The file is vendored into +// src/ (copied from @jitl/quickjs-wasmfile-release-sync) because wrangler's +// CompiledWasm module rule is rooted at the app dir and won't match the +// monorepo-root node_modules path — see scripts/vendor-quickjs-wasm.ts. +import wasmModule from "./quickjs-engine.wasm"; + +import { setQuickJSModule } from "@executor-js/runtime-quickjs"; + +// --------------------------------------------------------------------------- +// QuickJS-on-Workers WASM loading. +// +// The base variant's module loader resolves to the variant package's `workerd` +// build (its `./emscripten-module` export has a `workerd` condition wrangler +// selects) — that build expects the WASM module to be supplied rather than +// fetched/compiled at runtime. `newVariant(base, { wasmModule })` hands it the +// statically-imported, pre-compiled module, and `setQuickJSModule` makes every +// `makeQuickJsExecutor()` reuse it. Preloaded once per isolate. +// --------------------------------------------------------------------------- + +let preloaded: Promise | null = null; + +export const preloadQuickJs = (): Promise => { + if (!preloaded) { + const variant = newVariant(baseVariant, { wasmModule }); + preloaded = newQuickJSWASMModuleFromVariant(variant).then((mod) => { + setQuickJSModule(mod); + }); + } + return preloaded; +}; diff --git a/apps/host-cloudflare/src/wasm.d.ts b/apps/host-cloudflare/src/wasm.d.ts new file mode 100644 index 000000000..1bfd61f61 --- /dev/null +++ b/apps/host-cloudflare/src/wasm.d.ts @@ -0,0 +1,6 @@ +// On Cloudflare Workers, a `.wasm` import resolves to a pre-compiled +// `WebAssembly.Module` (wrangler's built-in CompiledWasm module rule). +declare module "*.wasm" { + const wasmModule: WebAssembly.Module; + export default wasmModule; +} diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts new file mode 100644 index 000000000..3c7d63402 --- /dev/null +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -0,0 +1,227 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; +import { unstable_dev, type Unstable_DevWorker } from "wrangler"; + +// --------------------------------------------------------------------------- +// End-to-end test for the Cloudflare host: boots the REAL worker on workerd via +// Miniflare (wrangler `unstable_dev`) with a local D1 + R2, dev-auth on. This is +// the only test that exercises the CF-specific stack together — D1 schema +// bring-up, the R2 large-value offload, QuickJS-WASM execution, and the MCP +// envelope — through the actual HTTP surface. +// --------------------------------------------------------------------------- + +const dir = fileURLToPath(new URL(".", import.meta.url)); + +// Inline spec (no network); registers one tool, exercising the D1 write path. +const SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + servers: [{ url: "https://example.com" }], + paths: { + "/ping": { get: { operationId: "ping", responses: { "200": { description: "ok" } } } }, + }, +}); + +describe("cloudflare host e2e (workerd/miniflare)", () => { + let worker: Unstable_DevWorker; + + beforeAll(async () => { + // CI runs from a fresh checkout with no `vite build`, so `./dist` (the SPA + // assets dir wrangler.jsonc points `assets.directory` at) is absent and + // `unstable_dev`'s assets validation aborts boot. This e2e drives the + // API/MCP surface (all `run_worker_first` paths), not the SPA, so a minimal + // placeholder index.html satisfies the validation without a real build. + const distIndex = resolve(dir, "../dist/index.html"); + if (!existsSync(distIndex)) { + mkdirSync(resolve(dir, "../dist"), { recursive: true }); + writeFileSync(distIndex, "executor"); + } + + worker = await unstable_dev(resolve(dir, "worker.ts"), { + config: resolve(dir, "../wrangler.jsonc"), + ip: "127.0.0.1", + local: true, + experimental: { disableExperimentalWarning: true }, + vars: { + EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + ENABLE_DEV_AUTH: "true", + }, + }); + }, 120_000); + + afterAll(async () => { + await worker?.stop(); + }); + + it("executes TypeScript via /api/executions (QuickJS on workerd)", async () => { + const res = await worker.fetch("/api/executions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: "export default 6 * 7" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { text: string; isError: boolean }; + expect(body.isError).toBe(false); + expect(body.text).toBe("42"); + }, 60_000); + + it("adds a LARGE OpenAPI source — exercises R2 offload (>800KB blob) + createMany batching (>100 tools)", async () => { + // Synthesize a spec big enough to (a) push the stored config blob past the + // ~800KB R2-offload threshold and (b) derive >100 tools (past D1's 100 + // bound-param createMany limit) — the real-worker regression for two of the + // three D1 fixes. + const paths: Record = {}; + for (let i = 0; i < 250; i++) { + paths[`/op${i}`] = { + get: { + operationId: `op${i}`, + summary: `operation ${i}`, + description: "d".repeat(4000), // padding -> ~1MB total spec + responses: { "200": { description: "ok" } }, + }, + }; + } + const largeSpec = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Large", version: "1.0.0" }, + servers: [{ url: "https://example.com" }], + paths, + }); + expect(largeSpec.length).toBeGreaterThan(900_000); + + const add = await worker.fetch("/api/scopes/default/openapi/specs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + spec: { kind: "blob", value: largeSpec }, + name: "Large API", + baseUrl: "https://example.com", + namespace: "largeapi", + }), + }); + expect(add.status).toBe(200); + const added = (await add.json()) as { toolCount: number }; + expect(added.toolCount).toBe(250); + + // Reads back through the R2 rehydration path (the >800KB blob lives in R2). + const got = await worker.fetch("/api/scopes/default/openapi/sources/largeapi"); + expect(got.status).toBe(200); + const source = (await got.json()) as { namespace: string } | null; + expect(source?.namespace).toBe("largeapi"); + }, 90_000); + + it("adds an OpenAPI source and reads it back (D1 write + read path)", async () => { + const add = await worker.fetch("/api/scopes/default/openapi/specs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + spec: { kind: "blob", value: SPEC }, + name: "Test API", + baseUrl: "https://example.com", + namespace: "testapi", + }), + }); + expect(add.status).toBe(200); + const added = (await add.json()) as { toolCount: number; namespace: string }; + expect(added.toolCount).toBeGreaterThan(0); + + const got = await worker.fetch("/api/scopes/default/openapi/sources/testapi"); + expect(got.status).toBe(200); + const source = (await got.json()) as { namespace: string } | null; + expect(source?.namespace).toBe("testapi"); + }, 60_000); + + it("gates the API when dev-auth is on but treats the request as the dev admin", async () => { + // dev-auth means the request is the fixed dev admin; /api/scope resolves. + const res = await worker.fetch("/api/scope"); + expect(res.status).toBe(200); + }); + + it("lists tools on a follow-up request after a fresh initialize (DO session survives across requests)", async () => { + // The production regression: `initialize` creates the session, then a + // SEPARATE `tools/list` request must find it. With the old in-process store a + // second Worker isolate never saw the session and this returned "Not + // connected"; the MCP-session Durable Object (id == session id) routes the + // follow-up back to the same isolate, so the tool list comes through. + const accept = "application/json, text/event-stream"; + const rpc = (sessionId: string | null, body: unknown) => + worker.fetch("/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + accept, + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }, + body: JSON.stringify(body), + }); + + const init = await rpc(null, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }, + }); + expect(init.status).toBe(200); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + await rpc(sessionId, { jsonrpc: "2.0", method: "notifications/initialized" }); + + const list = await rpc(sessionId, { jsonrpc: "2.0", id: 2, method: "tools/list" }); + expect(list.status).toBe(200); + const listed = (await list.json()) as { + result?: { tools?: ReadonlyArray<{ name: string }> }; + }; + const toolNames = listed.result?.tools?.map((t) => t.name) ?? []; + expect(toolNames).toContain("execute"); + }, 60_000); + + it("invokes the execute tool over MCP (initialize → tools/call → QuickJS)", async () => { + const accept = "application/json, text/event-stream"; + const rpc = (sessionId: string | null, body: unknown) => + worker.fetch("/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + accept, + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }, + body: JSON.stringify(body), + }); + + const init = await rpc(null, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }, + }); + expect(init.status).toBe(200); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + await rpc(sessionId, { jsonrpc: "2.0", method: "notifications/initialized" }); + + const call = await rpc(sessionId, { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "execute", arguments: { code: "export default 6 * 7" } }, + }); + expect(call.status).toBe(200); + const result = (await call.json()) as { + result?: { structuredContent?: { result?: number } }; + }; + expect(result.result?.structuredContent?.result).toBe(42); + }, 60_000); +}); diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts new file mode 100644 index 000000000..228b60a55 --- /dev/null +++ b/apps/host-cloudflare/src/worker.ts @@ -0,0 +1,30 @@ +import { makeCloudflareApp } from "./app"; +import type { CloudflareEnv } from "./config"; + +// The MCP session Durable Object class, bound as `MCP_SESSION` in wrangler.jsonc. +// Must be exported at the Worker entry module scope for the runtime to find it. +export { McpSessionDO } from "./mcp"; + +// --------------------------------------------------------------------------- +// The Worker fetch entry. `ExecutorApp.make`'s `toWebHandler()` produces a +// `(Request) => Promise` — exactly a Worker handler — so the entry is +// thin: build the app ONCE per isolate (memoized; the build runs the D1 schema +// bring-up), then forward every request to its handler. `env` (the D1 binding + +// Access vars) arrives with the request and is captured at build time. +// --------------------------------------------------------------------------- + +let handlerPromise: Promise<(request: Request) => Promise> | null = null; + +const resolveHandler = (env: CloudflareEnv): Promise<(request: Request) => Promise> => { + if (!handlerPromise) { + handlerPromise = makeCloudflareApp(env).then(({ toWebHandler }) => toWebHandler().handler); + } + return handlerPromise; +}; + +export default { + fetch: async (request: Request, env: CloudflareEnv): Promise => { + const serve = await resolveHandler(env); + return serve(request); + }, +}; diff --git a/apps/host-cloudflare/tsconfig.json b/apps/host-cloudflare/tsconfig.json new file mode 100644 index 000000000..f659f662d --- /dev/null +++ b/apps/host-cloudflare/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["@cloudflare/workers-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": { + "preferSchemaOverJson": "off" + } + } + ] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/host-cloudflare/vite.config.ts b/apps/host-cloudflare/vite.config.ts new file mode 100644 index 000000000..dc371ef79 --- /dev/null +++ b/apps/host-cloudflare/vite.config.ts @@ -0,0 +1,57 @@ +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import executorVitePlugin from "@executor-js/vite-plugin"; + +// --------------------------------------------------------------------------- +// Cloudflare web SPA. The SAME shared @executor-js/react shell + pages as cloud +// and self-host; the TanStack router codegen points at THIS app's routes +// (web/routes) so we get the multiplayer shell with the Cloudflare-Access root +// (no in-app login). `vite build` emits a static bundle to ./dist, which +// wrangler serves via Workers Static Assets (see wrangler.jsonc `assets`). +// `executorVitePlugin` feeds plugin client bundles from executor.config.ts into +// `virtual:executor/plugins-client`. +// +// No dev /api middleware here (self-host forwards to an in-process Bun handler); +// on Cloudflare you run `wrangler dev`, which serves the built SPA + the Worker +// API together. +// --------------------------------------------------------------------------- + +const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); + +export default defineConfig({ + root: fileURLToPath(new URL("./web/", import.meta.url)), + publicDir: fileURLToPath(new URL("../../packages/app/public/", import.meta.url)), + build: { + outDir: fileURLToPath(new URL("./dist/", import.meta.url)), + emptyOutDir: true, + }, + resolve: { + alias: { "@executor-app": APP_ROOT }, + dedupe: ["react", "react-dom"], + }, + define: { + "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-cloudflare"), + "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"), + }, + server: { + fs: { allow: [fileURLToPath(new URL("../../", import.meta.url))] }, + }, + plugins: [ + tailwindcss(), + executorVitePlugin({ + configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), + }), + tanstackRouter({ + target: "react", + autoCodeSplitting: true, + routesDirectory: fileURLToPath(new URL("./web/routes", import.meta.url)), + generatedRouteTree: fileURLToPath(new URL("./web/routeTree.gen.ts", import.meta.url)), + }), + ...react(), + ], +}); diff --git a/apps/host-cloudflare/vitest.config.ts b/apps/host-cloudflare/vitest.config.ts new file mode 100644 index 000000000..5bfa2d586 --- /dev/null +++ b/apps/host-cloudflare/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + passWithNoTests: true, + }, +}); diff --git a/apps/host-cloudflare/web/entry-client.tsx b/apps/host-cloudflare/web/entry-client.tsx new file mode 100644 index 000000000..7041999ed --- /dev/null +++ b/apps/host-cloudflare/web/entry-client.tsx @@ -0,0 +1,16 @@ +import ReactDOM from "react-dom/client"; +import { RouterProvider } from "@tanstack/react-router"; + +import "@executor-js/react/globals.css"; + +import { getRouter } from "./router"; + +// The whole app — shell, pages, and the multiplayer surface — is the shared +// @executor-js/react composition wired in routes/__root.tsx. Cloudflare Access +// is the identity (validated at the edge), so there is no login screen. +const router = getRouter(); +const rootElement = document.getElementById("root"); + +if (rootElement) { + ReactDOM.createRoot(rootElement).render(); +} diff --git a/apps/host-cloudflare/web/index.html b/apps/host-cloudflare/web/index.html new file mode 100644 index 000000000..820aac5e7 --- /dev/null +++ b/apps/host-cloudflare/web/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + Executor + + + + + +
+ + + diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts new file mode 100644 index 000000000..706fc9d11 --- /dev/null +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -0,0 +1,231 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ToolsRouteImport } from './routes/tools' +import { Route as SecretsRouteImport } from './routes/secrets' +import { Route as PoliciesRouteImport } from './routes/policies' +import { Route as ConnectionsRouteImport } from './routes/connections' +import { Route as IndexRouteImport } from './routes/index' +import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' +import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId' +import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' +import { Route as PluginsPluginIdSplatRouteImport } from './routes/plugins.$pluginId.$' + +const ToolsRoute = ToolsRouteImport.update({ + id: '/tools', + path: '/tools', + getParentRoute: () => rootRouteImport, +} as any) +const SecretsRoute = SecretsRouteImport.update({ + id: '/secrets', + path: '/secrets', + getParentRoute: () => rootRouteImport, +} as any) +const PoliciesRoute = PoliciesRouteImport.update({ + id: '/policies', + path: '/policies', + getParentRoute: () => rootRouteImport, +} as any) +const ConnectionsRoute = ConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ + id: '/sources/$namespace', + path: '/sources/$namespace', + getParentRoute: () => rootRouteImport, +} as any) +const ResumeExecutionIdRoute = ResumeExecutionIdRouteImport.update({ + id: '/resume/$executionId', + path: '/resume/$executionId', + getParentRoute: () => rootRouteImport, +} as any) +const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ + id: '/sources/add/$pluginKey', + path: '/sources/add/$pluginKey', + getParentRoute: () => rootRouteImport, +} as any) +const PluginsPluginIdSplatRoute = PluginsPluginIdSplatRouteImport.update({ + id: '/plugins/$pluginId/$', + path: '/plugins/$pluginId/$', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/connections': typeof ConnectionsRoute + '/policies': typeof PoliciesRoute + '/secrets': typeof SecretsRoute + '/tools': typeof ToolsRoute + '/resume/$executionId': typeof ResumeExecutionIdRoute + '/sources/$namespace': typeof SourcesNamespaceRoute + '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute + '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/connections': typeof ConnectionsRoute + '/policies': typeof PoliciesRoute + '/secrets': typeof SecretsRoute + '/tools': typeof ToolsRoute + '/resume/$executionId': typeof ResumeExecutionIdRoute + '/sources/$namespace': typeof SourcesNamespaceRoute + '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute + '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/connections': typeof ConnectionsRoute + '/policies': typeof PoliciesRoute + '/secrets': typeof SecretsRoute + '/tools': typeof ToolsRoute + '/resume/$executionId': typeof ResumeExecutionIdRoute + '/sources/$namespace': typeof SourcesNamespaceRoute + '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute + '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/connections' + | '/policies' + | '/secrets' + | '/tools' + | '/resume/$executionId' + | '/sources/$namespace' + | '/plugins/$pluginId/$' + | '/sources/add/$pluginKey' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/connections' + | '/policies' + | '/secrets' + | '/tools' + | '/resume/$executionId' + | '/sources/$namespace' + | '/plugins/$pluginId/$' + | '/sources/add/$pluginKey' + id: + | '__root__' + | '/' + | '/connections' + | '/policies' + | '/secrets' + | '/tools' + | '/resume/$executionId' + | '/sources/$namespace' + | '/plugins/$pluginId/$' + | '/sources/add/$pluginKey' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ConnectionsRoute: typeof ConnectionsRoute + PoliciesRoute: typeof PoliciesRoute + SecretsRoute: typeof SecretsRoute + ToolsRoute: typeof ToolsRoute + ResumeExecutionIdRoute: typeof ResumeExecutionIdRoute + SourcesNamespaceRoute: typeof SourcesNamespaceRoute + PluginsPluginIdSplatRoute: typeof PluginsPluginIdSplatRoute + SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/tools': { + id: '/tools' + path: '/tools' + fullPath: '/tools' + preLoaderRoute: typeof ToolsRouteImport + parentRoute: typeof rootRouteImport + } + '/secrets': { + id: '/secrets' + path: '/secrets' + fullPath: '/secrets' + preLoaderRoute: typeof SecretsRouteImport + parentRoute: typeof rootRouteImport + } + '/policies': { + id: '/policies' + path: '/policies' + fullPath: '/policies' + preLoaderRoute: typeof PoliciesRouteImport + parentRoute: typeof rootRouteImport + } + '/connections': { + id: '/connections' + path: '/connections' + fullPath: '/connections' + preLoaderRoute: typeof ConnectionsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/sources/$namespace': { + id: '/sources/$namespace' + path: '/sources/$namespace' + fullPath: '/sources/$namespace' + preLoaderRoute: typeof SourcesNamespaceRouteImport + parentRoute: typeof rootRouteImport + } + '/resume/$executionId': { + id: '/resume/$executionId' + path: '/resume/$executionId' + fullPath: '/resume/$executionId' + preLoaderRoute: typeof ResumeExecutionIdRouteImport + parentRoute: typeof rootRouteImport + } + '/sources/add/$pluginKey': { + id: '/sources/add/$pluginKey' + path: '/sources/add/$pluginKey' + fullPath: '/sources/add/$pluginKey' + preLoaderRoute: typeof SourcesAddPluginKeyRouteImport + parentRoute: typeof rootRouteImport + } + '/plugins/$pluginId/$': { + id: '/plugins/$pluginId/$' + path: '/plugins/$pluginId/$' + fullPath: '/plugins/$pluginId/$' + preLoaderRoute: typeof PluginsPluginIdSplatRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ConnectionsRoute: ConnectionsRoute, + PoliciesRoute: PoliciesRoute, + SecretsRoute: SecretsRoute, + ToolsRoute: ToolsRoute, + ResumeExecutionIdRoute: ResumeExecutionIdRoute, + SourcesNamespaceRoute: SourcesNamespaceRoute, + PluginsPluginIdSplatRoute: PluginsPluginIdSplatRoute, + SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/apps/host-cloudflare/web/router.tsx b/apps/host-cloudflare/web/router.tsx new file mode 100644 index 000000000..0d1f42651 --- /dev/null +++ b/apps/host-cloudflare/web/router.tsx @@ -0,0 +1,10 @@ +import { createRouter } from "@tanstack/react-router"; + +import { routeTree } from "./routeTree.gen"; + +export const getRouter = () => + createRouter({ + routeTree, + scrollRestoration: true, + defaultPreloadStaleTime: 0, + }); diff --git a/apps/host-cloudflare/web/routes/__root.tsx b/apps/host-cloudflare/web/routes/__root.tsx new file mode 100644 index 000000000..acd51a330 --- /dev/null +++ b/apps/host-cloudflare/web/routes/__root.tsx @@ -0,0 +1,72 @@ +import { createRootRoute } from "@tanstack/react-router"; +import { useEffect, type ReactNode } from "react"; + +import { ExecutorProvider } from "@executor-js/react/api/provider"; +import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { Toaster } from "@executor-js/react/components/sonner"; +import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context"; +import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; +import { plugins as clientPlugins } from "virtual:executor/plugins-client"; + +// --------------------------------------------------------------------------- +// Cloudflare root: the SAME shared multiplayer composition as cloud / self-host +// (AuthProvider → Shell → pages), with Cloudflare Access as the identity. +// +// Access authenticates the human at the edge BEFORE the request reaches the +// Worker, so there is no in-app login or first-run setup. `/account/me` (the +// CF AccountProvider) reflects the Access principal, so the auth gate only ever +// resolves to authenticated; the unauthenticated branch can only happen when +// Access isn't in front yet (or a JWT expired) — we bounce to the Access login. +// +// API keys + members are managed in Cloudflare Access, not in-app, so the +// API-keys footer is hidden (`apiKeysTo={null}`) and the nav is the default set. +// --------------------------------------------------------------------------- + +export const Route = createRootRoute({ + component: RootComponent, +}); + +// Sign-out is a redirect to Access's logout endpoint (it clears the Access +// session cookie); the next request re-prompts the Access login. +const signOut = () => { + window.location.href = "/cdn-cgi/access/logout"; +}; + +const Loading = ({ label }: { label: string }) => ( +
+ {label} +
+); + +function AuthGate({ children }: { children: ReactNode }) { + const auth = useAuth(); + + // Access already authenticated the user at the edge; an unauthenticated state + // means there's no live Access session (gate not configured, or expired) — + // send them through the Access login, which returns to the app with a JWT. + useEffect(() => { + if (auth.status === "unauthenticated") { + window.location.href = "/cdn-cgi/access/login"; + } + }, [auth.status]); + + if (auth.status === "authenticated") return <>{children}; + return ( + + ); +} + +function RootComponent() { + return ( + + + + + + + + + + + ); +} diff --git a/apps/host-cloudflare/web/routes/connections.tsx b/apps/host-cloudflare/web/routes/connections.tsx new file mode 100644 index 000000000..ae9f0af5a --- /dev/null +++ b/apps/host-cloudflare/web/routes/connections.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ConnectionsPage } from "@executor-js/react/pages/connections"; + +export const Route = createFileRoute("/connections")({ + component: () => , +}); diff --git a/apps/host-cloudflare/web/routes/index.tsx b/apps/host-cloudflare/web/routes/index.tsx new file mode 100644 index 000000000..01273b87a --- /dev/null +++ b/apps/host-cloudflare/web/routes/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SourcesPage } from "@executor-js/react/pages/sources"; + +export const Route = createFileRoute("/")({ + component: SourcesPage, +}); diff --git a/apps/host-cloudflare/web/routes/plugins.$pluginId.$.tsx b/apps/host-cloudflare/web/routes/plugins.$pluginId.$.tsx new file mode 100644 index 000000000..472ab9768 --- /dev/null +++ b/apps/host-cloudflare/web/routes/plugins.$pluginId.$.tsx @@ -0,0 +1,43 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { useClientPlugins } from "@executor-js/sdk/client"; + +// --------------------------------------------------------------------------- +// /plugins// +// +// Mounts pages contributed by client plugins. The host's +// `` (set up at the root) materialises the +// list of `ClientPluginSpec` from `virtual:executor/plugins-client`, +// and this route reads it via `useClientPlugins()` — so adding a +// plugin to `executor.config.ts` is sufficient for its pages to mount +// here, with no per-route imports. +// +// Match logic is intentionally tiny: exact path equality between the URL +// remainder and a `PageDecl.path`, with `""` and `/` treated as the +// same root. Plugins that want parameterized paths can build their own +// in-component routing for now. +// --------------------------------------------------------------------------- + +export const Route = createFileRoute("/plugins/$pluginId/$")({ + component: PluginRouteComponent, +}); + +function normalizePath(input: string): string { + if (!input || input === "/") return "/"; + return input.startsWith("/") ? input : `/${input}`; +} + +function PluginRouteComponent() { + const { pluginId, _splat: rest } = Route.useParams(); + const plugins = useClientPlugins(); + const plugin = plugins.find((p) => p.id === pluginId); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() + if (!plugin) throw notFound(); + + const target = normalizePath(rest ?? "/"); + const page = plugin.pages?.find((p) => normalizePath(p.path) === target); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() + if (!page) throw notFound(); + + const Component = page.component; + return ; +} diff --git a/apps/host-cloudflare/web/routes/policies.tsx b/apps/host-cloudflare/web/routes/policies.tsx new file mode 100644 index 000000000..a9de9ff6f --- /dev/null +++ b/apps/host-cloudflare/web/routes/policies.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PoliciesPage } from "@executor-js/react/pages/policies"; + +export const Route = createFileRoute("/policies")({ + component: () => , +}); diff --git a/apps/host-cloudflare/web/routes/resume.$executionId.tsx b/apps/host-cloudflare/web/routes/resume.$executionId.tsx new file mode 100644 index 000000000..32a84347b --- /dev/null +++ b/apps/host-cloudflare/web/routes/resume.$executionId.tsx @@ -0,0 +1,117 @@ +import { useCallback } from "react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Data, Effect, Option, Schema } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { createFileRoute } from "@tanstack/react-router"; +import { + ResumeApprovalPage, + ResumeApprovalPageView, +} from "@executor-js/react/pages/resume-approval"; +import { pausedExecutionAtom } from "@executor-js/react/api/atoms"; +import type { ElicitationAction } from "@executor-js/react/components/elicitation-approval"; + +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + mcp_session_id: Schema.optional(Schema.String), + }), +); +const LocalMcpResumeCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + text: Schema.String, + structured: Schema.Unknown, + isError: Schema.Boolean, +}); +const LocalMcpResumePaused = Schema.Struct({ + status: Schema.Literal("paused"), + text: Schema.String, + structured: Schema.Unknown, +}); +const LocalMcpResumeResult = Schema.Union([LocalMcpResumeCompleted, LocalMcpResumePaused]); +const decodeLocalMcpResumeResult = Schema.decodeUnknownOption(LocalMcpResumeResult); + +class LocalMcpResumeError extends Data.TaggedError("LocalMcpResumeError")<{ + readonly message: string; +}> {} + +type LocalMcpResumeInput = { + readonly mcpSessionId: string; + readonly executionId: string; + readonly action: ElicitationAction; + readonly content?: Record; +}; + +const resumeLocalMcpExecution = Atom.fn()((input) => + Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: () => + fetch( + `/api/mcp-sessions/${encodeURIComponent(input.mcpSessionId)}/executions/${encodeURIComponent(input.executionId)}/resume`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + input.action === "accept" + ? { action: input.action, content: input.content ?? {} } + : { action: input.action }, + ), + }, + ), + catch: () => new LocalMcpResumeError({ message: "Failed to submit approval." }), + }); + + if (!response.ok) { + const body = yield* Effect.tryPromise({ + try: () => response.text(), + catch: () => "", + }).pipe(Effect.orElseSucceed(() => "")); + return yield* new LocalMcpResumeError({ + message: body || `Approval request failed (${response.status}).`, + }); + } + + const body = yield* Effect.tryPromise({ + try: () => response.json(), + catch: () => new LocalMcpResumeError({ message: "Approval response was not valid JSON." }), + }); + const result = decodeLocalMcpResumeResult(body); + if (Option.isNone(result)) { + return yield* new LocalMcpResumeError({ + message: "Approval response had an unexpected shape.", + }); + } + return result.value; + }), +); + +export const Route = createFileRoute("/resume/$executionId")({ + validateSearch: SearchParams, + component: RouteComponent, +}); + +function RouteComponent() { + const { executionId } = Route.useParams(); + const { mcp_session_id: mcpSessionId } = Route.useSearch(); + if (mcpSessionId) { + return ; + } + return ; +} + +function LocalMcpResumeApproval(props: { executionId: string; mcpSessionId: string }) { + const paused = useAtomValue(pausedExecutionAtom(props.executionId)); + const doResume = useAtomSet(resumeLocalMcpExecution, { mode: "promiseExit" }); + const resume = useCallback( + (executionId: string, action: ElicitationAction, content?: Record) => + doResume({ mcpSessionId: props.mcpSessionId, executionId, action, content }), + [doResume, props.mcpSessionId], + ); + + return ( + + ); +} diff --git a/apps/host-cloudflare/web/routes/secrets.tsx b/apps/host-cloudflare/web/routes/secrets.tsx new file mode 100644 index 000000000..cdf46a221 --- /dev/null +++ b/apps/host-cloudflare/web/routes/secrets.tsx @@ -0,0 +1,25 @@ +import { Schema } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { SecretsPage } from "@executor-js/react/pages/secrets"; + +// Query params supported by the agent-facing `secrets.create` static tool: +// it builds a URL like `/secrets?name=…&scope=…&secretId=…` and hands +// it to the user. The page opens the add modal pre-filled when any +// prefill field is present so the user only has to type the value. +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + name: Schema.optional(Schema.String), + secretId: Schema.optional(Schema.String), + provider: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + }), +); + +export const Route = createFileRoute("/secrets")({ + validateSearch: SearchParams, + component: () => { + const { name, secretId, provider, scope } = Route.useSearch(); + const hasPrefill = name != null || secretId != null; + return ; + }, +}); diff --git a/apps/host-cloudflare/web/routes/sources.$namespace.tsx b/apps/host-cloudflare/web/routes/sources.$namespace.tsx new file mode 100644 index 000000000..2bcdcce73 --- /dev/null +++ b/apps/host-cloudflare/web/routes/sources.$namespace.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SourceDetailPage } from "@executor-js/react/pages/source-detail"; + +export const Route = createFileRoute("/sources/$namespace")({ + component: () => { + const { namespace } = Route.useParams(); + return ; + }, +}); diff --git a/apps/host-cloudflare/web/routes/sources.add.$pluginKey.tsx b/apps/host-cloudflare/web/routes/sources.add.$pluginKey.tsx new file mode 100644 index 000000000..a1618a00a --- /dev/null +++ b/apps/host-cloudflare/web/routes/sources.add.$pluginKey.tsx @@ -0,0 +1,19 @@ +import { Schema } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { SourcesAddPage } from "@executor-js/react/pages/sources-add"; + +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + url: Schema.optional(Schema.String), + preset: Schema.optional(Schema.String), + }), +); + +export const Route = createFileRoute("/sources/add/$pluginKey")({ + validateSearch: SearchParams, + component: () => { + const { pluginKey } = Route.useParams(); + const { url, preset } = Route.useSearch(); + return ; + }, +}); diff --git a/apps/host-cloudflare/web/routes/tools.tsx b/apps/host-cloudflare/web/routes/tools.tsx new file mode 100644 index 000000000..25929fd2b --- /dev/null +++ b/apps/host-cloudflare/web/routes/tools.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ToolsPage } from "@executor-js/react/pages/tools"; + +export const Route = createFileRoute("/tools")({ + component: ToolsPage, +}); diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc new file mode 100644 index 000000000..4e9aba169 --- /dev/null +++ b/apps/host-cloudflare/wrangler.jsonc @@ -0,0 +1,64 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "executor-cloudflare", + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_compat"], + "main": "src/worker.ts", + "observability": { "enabled": true }, + // The web UI (Workers Static Assets) — the shared multiplayer SPA built by + // `vite build` into ./dist. `single-page-application` serves index.html for + // client routes (e.g. /policies); `run_worker_first` forces the API + MCP + // paths to the Worker instead of the SPA fallback. + "assets": { + "directory": "./dist", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api/*", "/mcp", "/mcp/*"], + }, + // D1 is the app's SQLite store (the DbProvider seam). `wrangler deploy` + // auto-provisions it on first deploy; replace database_id after that, or run + // `wrangler d1 create executor` and paste the id here. + "d1_databases": [ + { + "binding": "DB", + "database_name": "executor", + "database_id": "ae748ca1-032c-4427-a1a0-fe39db77d1a9", + }, + ], + // R2 holds oversized values that exceed D1's per-value cap (~1-2MB). The D1 + // handle offloads large bound params to this bucket and stores a pointer in + // the row (apps/host-cloudflare/src/db/r2-blob-offload.ts). `wrangler r2 + // bucket create executor-blobs` provisions it. + "r2_buckets": [ + { + "binding": "BLOBS", + "bucket_name": "executor-blobs", + }, + ], + // The MCP session Durable Object: one addressable isolate per MCP session (the + // DO id IS the session id) so a session survives across the Worker's stateless + // isolates — without it, `tools/list` after `initialize` can land on a fresh + // isolate that never saw the session ("Not connected"). `new_sqlite_classes` + // is the free-tier-eligible SQLite-backed DO storage. + "durable_objects": { + "bindings": [{ "name": "MCP_SESSION", "class_name": "McpSessionDO" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["McpSessionDO"] }], + // Cloudflare Access is the entire auth layer: the Worker validates the + // Cf-Access-Jwt-Assertion JWT against the team JWKS. Set these to your Zero + // Trust team domain + the Access application's AUD tag. EXECUTOR_SECRET_KEY + // (the at-rest secret-encryption key) is a SECRET — set it with + // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. + "vars": { + "ACCESS_TEAM_DOMAIN": "your-team.cloudflareaccess.com", + "ACCESS_AUD": "", + "ACCESS_NAME_CLAIM": "name", + "ACCESS_GROUPS_CLAIM": "groups", + "ADMIN_EMAILS": "", + "SELF_HOSTED_ORG_ID": "default", + "SELF_HOSTED_ORG_NAME": "Default", + // VITE_PUBLIC_SITE_URL is intentionally unset: with no static URL the worker + // derives the web base URL from each request's origin (RequestWebOrigin), so + // secret/OAuth handoff links match whatever host the user actually reached. + // Set it only to force a canonical URL (e.g. behind a proxy that rewrites Host). + }, +} diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example new file mode 100644 index 000000000..659751596 --- /dev/null +++ b/apps/host-selfhost/.env.example @@ -0,0 +1,32 @@ +# Self-hosted Executor configuration. Copy to `.env` and uncomment what you need. +# +# Everything here is OPTIONAL. A bare `docker compose up` boots a fully working +# instance and walks you through creating the admin account in the browser. + +# Public URL browsers use to reach this instance. It MUST exactly match the +# address you load in the browser (scheme + host + port), or browser logins are +# rejected. Behind a reverse proxy / TLS, set this to your public https URL. +# EXECUTOR_WEB_BASE_URL=https://executor.example.com + +# --- Session secret ----------------------------------------------------------- +# Generated and persisted under the data volume on first boot if unset. Set this +# to manage it yourself (must be at least 32 characters). Rotating it signs every +# user out. +# BETTER_AUTH_SECRET= + +# --- Headless bootstrap admin (CI / infra-as-code) ---------------------------- +# Set BOTH to pre-create the admin instead of the in-browser first-run setup. +# Leave both unset for the browser setup flow (recommended for most deploys). +# EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=you@example.com +# EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD=change-me-to-something-strong +# EXECUTOR_BOOTSTRAP_ADMIN_NAME=Admin + +# --- Organization ------------------------------------------------------------- +# Display name and slug for the single organization every user belongs to. +# EXECUTOR_ORG_NAME=Default +# EXECUTOR_ORG_SLUG=default + +# --- Sandbox network ---------------------------------------------------------- +# Allow sandboxed code to reach loopback/private network addresses. Off by +# default — adversarial generated code should not reach your internal network. +# EXECUTOR_ALLOW_LOCAL_NETWORK=false diff --git a/apps/host-selfhost/CHANGELOG.md b/apps/host-selfhost/CHANGELOG.md new file mode 100644 index 000000000..208ebc58c --- /dev/null +++ b/apps/host-selfhost/CHANGELOG.md @@ -0,0 +1,6 @@ +# @executor-js/host-selfhost changelog + +This file exists for `changesets/action@v1` compatibility (it reads every +workspace package's `CHANGELOG.md` to build the Version Packages PR). +Canonical user-facing release notes are at `apps/cli/release-notes/next.md` +and on the GitHub Releases page. diff --git a/apps/host-selfhost/Dockerfile b/apps/host-selfhost/Dockerfile new file mode 100644 index 000000000..2d24328bd --- /dev/null +++ b/apps/host-selfhost/Dockerfile @@ -0,0 +1,49 @@ +# Self-hosted Executor — single container, no external services. +# Build context is the REPO ROOT (the bun workspace install needs every member): +# docker build -f apps/host-selfhost/Dockerfile -t executor-selfhost . +# +# Runtime needs nothing but this container + a volume for the data dir: +# docker run -p 4788:4788 -e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \ +# -e EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=you@example.com \ +# -e EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD=... \ +# -e EXECUTOR_WEB_BASE_URL=https://your.domain \ +# -v executor-data:/data executor-selfhost +# +# SQLite (libSQL, file:/data/...) lives in /data, QuickJS + MCP run in-process — +# so there's no postgres/worker/proxy to orchestrate. + +# ── Build stage: install the workspace + build the SPA ────────────────────── +FROM oven/bun:1 AS build +WORKDIR /app +COPY . . +# Full install (dev deps included — vite/turbo/plugins are needed to build). +RUN bun install --frozen-lockfile +# Builds @executor-js/vite-plugin (via turbo) then the self-host SPA into +# apps/host-selfhost/dist. +RUN cd apps/host-selfhost && bun run build +# Reinstall PRODUCTION deps only, so the runtime image excludes the build/dev +# toolchain pulled by the full workspace install — vite, turbo, wrangler → +# miniflare → sharp/libvips (~800MB), astro, vitest, etc. The built SPA lives in +# apps/host-selfhost/dist (outside node_modules), so it survives the reinstall. +# --ignore-scripts: the root `prepare` hook runs a dev-only tool +# (effect-language-service); runtime deps are prebuilt JS with no postinstall. +RUN rm -rf node_modules && bun install --frozen-lockfile --production --ignore-scripts + +# ── Runtime stage: serve the built app under Bun ──────────────────────────── +FROM oven/bun:1 AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + EXECUTOR_HOST=0.0.0.0 \ + PORT=4788 \ + EXECUTOR_DATA_DIR=/data +COPY --from=build /app /app +WORKDIR /app/apps/host-selfhost +RUN mkdir -p /data +VOLUME ["/data"] +EXPOSE 4788 +# Readiness probe against the public /api/health endpoint (a trivial DB ping). +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 \ + CMD bun -e "fetch('http://127.0.0.1:4788/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" +# serve.ts binds the Effect AppLayer (API + /mcp + /api/auth + /docs) and serves +# the built SPA from ./dist — one process. +CMD ["bun", "run", "src/serve.ts"] diff --git a/apps/host-selfhost/README.md b/apps/host-selfhost/README.md new file mode 100644 index 000000000..41f997cd8 --- /dev/null +++ b/apps/host-selfhost/README.md @@ -0,0 +1,47 @@ +# Self-hosted Executor + +The single-container, self-hostable Executor server: the typed API, the MCP +server, Better Auth (cookie / bearer / API-key + MCP OAuth), QuickJS code +execution, and the web UI — all in one process over a libSQL (SQLite) file. No +external database, worker, or proxy. + +## Run it + +```bash +# From this directory: +docker compose up -d --build +# then open http://localhost:4788 and create the admin account +``` + +No configuration is required. A fresh instance shows a setup screen; the first +person to create an account becomes the owner. After that, people join via +single-use invite links you mint from the **Admin** page, and self-service +signup is closed. + +See [`.env.example`](./.env.example) for optional settings (most importantly +`EXECUTOR_WEB_BASE_URL` behind a domain / TLS) and the full +[Self-Hosting guide](../../docs/self-hosting/guide.mdx) for first-run, inviting +people, backups, reverse-proxy setup, and upgrades. + +## Develop + +```bash +bun run build # build the SPA (regenerates the route tree) +bun run src/serve.ts # serve the built app +bun run --filter @executor-js/host-selfhost test # the test suite +``` + +## Layout + +``` +src/ + app.ts the ExecutorApp.make composition root + serve.ts the Bun server entry + config.ts env + zero-config secret/key persistence + auth/ Better Auth wiring, the signup gate, invite codes, seed + account/ the AccountProvider seam (members/roles via the org plugin) + admin/ the invite-code admin HttpApi + system/ public /api/health + /api/setup-status + db/ · mcp/ · execution.ts · plugins.ts · observability.ts +web/ the TanStack Router SPA (setup, login, join, admin, …) +``` diff --git a/apps/host-selfhost/docker-compose.yml b/apps/host-selfhost/docker-compose.yml new file mode 100644 index 000000000..ed6cc80de --- /dev/null +++ b/apps/host-selfhost/docker-compose.yml @@ -0,0 +1,42 @@ +# One-command self-hosted Executor. +# +# docker compose up -d --build # build the image and start +# open http://localhost:4788 # create the admin account (first-run) +# +# Everything — SQLite (libSQL), QuickJS code execution, and the MCP server — runs +# in this single container. The named volume persists the database and the +# generated keys across restarts and upgrades. Nothing else to orchestrate. +# +# No configuration is required: a bare `docker compose up` boots a working +# instance and walks you through creating the admin account in the browser. +# Optional settings live in .env (see .env.example) — most importantly +# EXECUTOR_WEB_BASE_URL when serving behind a domain / TLS. + +services: + executor: + build: + # Build context is the repo root: the Bun workspace install needs every member. + context: ../.. + dockerfile: apps/host-selfhost/Dockerfile + image: executor-selfhost + restart: unless-stopped + ports: + - "4788:4788" + env_file: + - path: .env + required: false + volumes: + - executor-data:/data + healthcheck: + test: + - CMD + - bun + - -e + - "fetch('http://127.0.0.1:4788/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + +volumes: + executor-data: diff --git a/apps/host-selfhost/executor.config.ts b/apps/host-selfhost/executor.config.ts new file mode 100644 index 000000000..1d41b2d7e --- /dev/null +++ b/apps/host-selfhost/executor.config.ts @@ -0,0 +1,28 @@ +import { defineExecutorConfig } from "@executor-js/sdk"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; +import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; + +import { resolveSecretKey } from "./src/config"; + +// --------------------------------------------------------------------------- +// Single source of truth for the self-hosted app's plugin list. +// +// Self-host runs the same protocol/provider plugins as cloud, minus the +// multi-tenant-only secret backends (WorkOS Vault). `dangerouslyAllowStdioMCP` +// is false: a server reachable by multiple users must not let one user spawn +// arbitrary stdio MCP processes on the host. The encrypted DB secret provider +// (slice 4) is added here as the first writable secret provider. +// --------------------------------------------------------------------------- + +export default defineExecutorConfig({ + plugins: () => + [ + openApiHttpPlugin(), + mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), + graphqlHttpPlugin(), + // First writable secret provider -> the default for `secrets.set`. + encryptedSecretsPlugin({ key: resolveSecretKey() }), + ] as const, +}); diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json new file mode 100644 index 000000000..d967253a9 --- /dev/null +++ b/apps/host-selfhost/package.json @@ -0,0 +1,58 @@ +{ + "name": "@executor-js/host-selfhost", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./serve": "./src/serve.ts" + }, + "scripts": { + "dev": "bunx --bun vite dev", + "build": "turbo run build --filter @executor-js/vite-plugin && vite build", + "start": "bun run src/serve.ts", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@better-auth/api-key": "^1.6.11", + "@effect/atom-react": "catalog:", + "@effect/platform-bun": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/app": "workspace:*", + "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-encrypted-secrets": "workspace:*", + "@executor-js/plugin-graphql": "workspace:*", + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/react": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@libsql/client": "catalog:", + "@libsql/kysely-libsql": "catalog:", + "@modelcontextprotocol/sdk": "^1.29.0", + "@tanstack/react-router": "catalog:", + "better-auth": "^1.6.11", + "drizzle-orm": "catalog:", + "effect": "catalog:", + "fumadb": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/vite-plugin": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@tanstack/router-plugin": "^1.167.12", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "bun-types": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/apps/host-selfhost/src/account/account-api.ts b/apps/host-selfhost/src/account/account-api.ts new file mode 100644 index 000000000..c39863c51 --- /dev/null +++ b/apps/host-selfhost/src/account/account-api.ts @@ -0,0 +1,27 @@ +import { Layer } from "effect"; + +import { accountProviderMiddlewareLayer } from "@executor-js/api/server"; + +import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; +import { betterAuthAccountProvider } from "./better-auth-account-provider"; + +// --------------------------------------------------------------------------- +// Self-host account seam: the per-request `AccountProvider` middleware backed by +// Better Auth. `ExecutorApp.make` mounts the shared, provider-neutral +// `AccountHandlers` behind it under the `/api` prefix (same prefixed router as +// the plugin API). The provider does its OWN auth (each handler resolves the +// session via the request headers), so it is NOT wrapped by the execution-stack +// middleware — account requests never build a code executor. +// +// The handlers `yield* AccountProvider` at request time; providing it through a +// router middleware (like the plugin API's ExecutionStackMiddleware provides +// ExecutorService) satisfies the requirement without leaking into the app +// layer's output. The Better Auth `AccountProvider` is self-contained, so it +// goes through the common-case `accountProviderMiddlewareLayer` (wraps it in +// `requestScopedMiddleware`). +// --------------------------------------------------------------------------- + +export const selfHostAccountMiddleware = (betterAuth: BetterAuthHandle) => + accountProviderMiddlewareLayer( + betterAuthAccountProvider.pipe(Layer.provide(Layer.succeed(BetterAuth)(betterAuth))), + ); diff --git a/apps/host-selfhost/src/account/better-auth-account-provider.ts b/apps/host-selfhost/src/account/better-auth-account-provider.ts new file mode 100644 index 000000000..48f7b6afe --- /dev/null +++ b/apps/host-selfhost/src/account/better-auth-account-provider.ts @@ -0,0 +1,179 @@ +import { Effect, Layer } from "effect"; + +import { AccountProvider, type AccountHeaders } from "@executor-js/api/server"; +import { AccountError, AccountUnauthorized } from "@executor-js/api"; + +import { BetterAuth } from "../auth/better-auth"; + +// --------------------------------------------------------------------------- +// Self-host AccountProvider — implements the provider-neutral account surface +// over the Better Auth instance (auth.api.*). The shared AccountHandlers call +// this; cloud provides its own WorkOS-backed implementation of the same shape. +// +// Single-org instance: organization id/name come from the boot-seeded org. +// auth.api.* throws on failure; we map those to the neutral AccountError so the +// UI sees one shape. API keys returned by `list` only expose a masked value; +// the plaintext is returned once, by `create`. +// --------------------------------------------------------------------------- + +const toHeaders = (headers: AccountHeaders): Headers => new Headers(headers); + +const isoOrNull = (value: Date | string | null | undefined): string | null => { + if (!value) return null; + return value instanceof Date ? value.toISOString() : value; +}; + +const iso = (value: Date | string | null | undefined): string => isoOrNull(value) ?? ""; + +// Better Auth exposes only `start` (leading chars) for display once a key is +// stored; render it as a masked token. +const masked = (start: string | null | undefined): string => (start ? `${start}…` : "••••••••"); + +// Narrow a free-form role slug to the Better Auth organization role union +// (defaults to member). Returning literals — not a cast — keeps the types sound. +const orgRole = (slug: string | undefined): "owner" | "admin" | "member" => + slug === "owner" ? "owner" : slug === "admin" ? "admin" : "member"; + +export const betterAuthAccountProvider: Layer.Layer = + Layer.effect(AccountProvider)( + Effect.gen(function* () { + const { auth, organizationId, organizationName } = yield* BetterAuth; + + const getSession = (headers: AccountHeaders) => + Effect.tryPromise({ + try: () => auth.api.getSession({ headers: toHeaders(headers) }), + catch: () => new AccountError({ message: "Failed to resolve session" }), + }).pipe(Effect.orElseSucceed(() => null)); + + // Run a Better Auth api call, mapping any rejection to a neutral + // AccountError with a stable, user-facing message. + const call =
(message: string, run: () => Promise) => + Effect.tryPromise({ try: run, catch: () => new AccountError({ message }) }); + + return AccountProvider.of({ + me: (headers) => + Effect.gen(function* () { + const resolved = yield* getSession(headers); + if (!resolved) return yield* new AccountUnauthorized(); + return { + user: { + id: resolved.user.id, + email: resolved.user.email, + name: resolved.user.name ?? null, + avatarUrl: resolved.user.image ?? null, + }, + organization: { + id: resolved.session.activeOrganizationId ?? organizationId, + name: organizationName, + }, + }; + }), + + listApiKeys: (headers) => + call("Failed to list API keys", () => + auth.api.listApiKeys({ headers: toHeaders(headers) }), + ).pipe( + Effect.map((result) => ({ + apiKeys: result.apiKeys.map((key) => ({ + id: key.id, + name: key.name ?? "API key", + obfuscatedValue: masked(key.start), + createdAt: iso(key.createdAt), + updatedAt: iso(key.updatedAt), + lastUsedAt: isoOrNull(key.lastRequest), + })), + })), + ), + + createApiKey: (headers, name) => + call("Failed to create API key", () => + auth.api.createApiKey({ body: { name }, headers: toHeaders(headers) }), + ).pipe( + Effect.map((key) => ({ + id: key.id, + name: key.name ?? name, + obfuscatedValue: masked(key.start), + createdAt: iso(key.createdAt), + updatedAt: iso(key.updatedAt), + lastUsedAt: isoOrNull(key.lastRequest), + value: key.key, + })), + ), + + revokeApiKey: (headers, apiKeyId) => + call("Failed to revoke API key", () => + auth.api.deleteApiKey({ body: { keyId: apiKeyId }, headers: toHeaders(headers) }), + ).pipe(Effect.as({ success: true })), + + listMembers: (headers) => + Effect.gen(function* () { + const resolved = yield* getSession(headers); + const currentUserId = resolved?.user.id; + const result = yield* call("Failed to list members", () => + auth.api.listMembers({ headers: toHeaders(headers) }), + ).pipe( + Effect.catchTag("AccountError", () => Effect.succeed({ members: [], total: 0 })), + ); + const members = result.members.map((member) => ({ + id: member.id, + userId: member.userId, + email: member.user?.email ?? "", + name: member.user?.name ?? null, + avatarUrl: member.user?.image ?? null, + role: member.role, + status: "active", + lastActiveAt: null, + isCurrentUser: member.userId === currentUserId, + })); + return { + members, + seats: { used: members.length, granted: members.length, unlimited: true }, + }; + }), + + // Better Auth's organization plugin ships fixed roles; expose the common + // set so the invite/role UI has options on a single-team instance. + listRoles: () => + Effect.succeed({ + roles: [ + { slug: "owner", name: "Owner" }, + { slug: "admin", name: "Admin" }, + { slug: "member", name: "Member" }, + ], + }), + + inviteMember: (headers, body) => + call("Failed to invite member", () => + auth.api.createInvitation({ + // Narrow the free-form slug to the org plugin's role union (no cast). + body: { email: body.email, role: orgRole(body.roleSlug) }, + headers: toHeaders(headers), + }), + ).pipe(Effect.map((invite) => ({ id: invite.id, email: invite.email }))), + + removeMember: (headers, membershipId) => + call("Failed to remove member", () => + auth.api.removeMember({ + body: { memberIdOrEmail: membershipId }, + headers: toHeaders(headers), + }), + ).pipe(Effect.as({ success: true })), + + updateMemberRole: (headers, membershipId, roleSlug) => + call("Failed to update member role", () => + auth.api.updateMemberRole({ + body: { memberId: membershipId, role: roleSlug }, + headers: toHeaders(headers), + }), + ).pipe(Effect.as({ success: true })), + + updateOrgName: (headers, name) => + call("Failed to update organization name", () => + auth.api.updateOrganization({ + body: { data: { name }, organizationId }, + headers: toHeaders(headers), + }), + ).pipe(Effect.as({ name })), + }); + }), + ); diff --git a/apps/host-selfhost/src/account/index.ts b/apps/host-selfhost/src/account/index.ts new file mode 100644 index 000000000..96e281d11 --- /dev/null +++ b/apps/host-selfhost/src/account/index.ts @@ -0,0 +1,7 @@ +// The self-host account surface: the per-request `AccountProvider` middleware +// backed by Better Auth. `ExecutorApp.make` mounts the shared, provider-neutral +// `AccountHandlers` behind it under /api (Better-Auth-only — the test stub path +// doesn't serve it). `selfHostAccountMiddleware` builds the middleware Layer from +// a Better Auth handle. +export { selfHostAccountMiddleware } from "./account-api"; +export { betterAuthAccountProvider } from "./better-auth-account-provider"; diff --git a/apps/host-selfhost/src/admin/api.ts b/apps/host-selfhost/src/admin/api.ts new file mode 100644 index 000000000..449ad2e98 --- /dev/null +++ b/apps/host-selfhost/src/admin/api.ts @@ -0,0 +1,92 @@ +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; + +// --------------------------------------------------------------------------- +// Self-host admin API — the invite-code surface (app-local, self-host only). +// +// Member/role management is the shared, provider-neutral /account/* surface +// (served by the Better Auth AccountProvider, rendered by the shared org page). +// Invite CODES are self-host's join mechanism and have no neutral equivalent — +// cloud joins via WorkOS — so they live in this app-local group, served +// alongside the core API under /api and consumed by a self-host atom client. +// +// Browser-safe: schemas + the HttpApi value only (no server imports), so the +// web client can build a typed AtomHttpApi from it. +// --------------------------------------------------------------------------- + +export class AdminError extends Schema.TaggedErrorClass()( + "AdminError", + { message: Schema.String }, + { httpApiStatus: 500 }, +) {} + +export class AdminUnauthorized extends Schema.TaggedErrorClass()( + "AdminUnauthorized", + {}, + { httpApiStatus: 401 }, +) {} + +export class AdminForbidden extends Schema.TaggedErrorClass()( + "AdminForbidden", + {}, + { httpApiStatus: 403 }, +) {} + +export const InviteCode = Schema.Struct({ + id: Schema.String, + code: Schema.String, + role: Schema.String, + label: Schema.NullOr(Schema.String), + createdAt: Schema.String, + expiresAt: Schema.NullOr(Schema.String), + usedByEmail: Schema.NullOr(Schema.String), + usedAt: Schema.NullOr(Schema.String), +}); + +export const InvitesResponse = Schema.Struct({ + invites: Schema.Array(InviteCode), +}); + +export const CreateInviteBody = Schema.Struct({ + role: Schema.optional(Schema.String), + label: Schema.optional(Schema.String), + expiresInDays: Schema.optional(Schema.NullOr(Schema.Number)), +}); + +export const SuccessResponse = Schema.Struct({ + success: Schema.Boolean, +}); + +const InviteParams = { inviteId: Schema.String }; + +// Paths are `/admin/*` (no `/api`): the server mounts this on the same +// `/api`-prefixed router as the core API, and the client prepends the `/api` +// base — symmetric with the account API. +export const AdminApi = HttpApiGroup.make("admin") + .add( + HttpApiEndpoint.get("listInvites", "/admin/invites", { + success: InvitesResponse, + error: [AdminError, AdminUnauthorized, AdminForbidden], + }), + ) + .add( + HttpApiEndpoint.post("createInvite", "/admin/invites", { + payload: CreateInviteBody, + success: InviteCode, + error: [AdminError, AdminUnauthorized, AdminForbidden], + }), + ) + .add( + HttpApiEndpoint.delete("revokeInvite", "/admin/invites/:inviteId", { + params: InviteParams, + success: SuccessResponse, + error: [AdminError, AdminUnauthorized, AdminForbidden], + }), + ); + +/** + * Standalone HttpApi wrapping the admin group — used to build the self-host + * `AdminApiClient` atoms in the web app, and mounted server-side as an + * extension route layer. + */ +export const AdminHttpApi = HttpApi.make("executor-self-host-admin").add(AdminApi); diff --git a/apps/host-selfhost/src/admin/handlers.ts b/apps/host-selfhost/src/admin/handlers.ts new file mode 100644 index 000000000..592eba2dc --- /dev/null +++ b/apps/host-selfhost/src/admin/handlers.ts @@ -0,0 +1,139 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; + +import { + AdminError, + AdminForbidden, + AdminHttpApi, + AdminUnauthorized, + type InviteCode as InviteCodeSchema, +} from "./api"; +import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; +import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; +import { + createInviteCode, + listInviteCodes, + revokeInviteCode, + type InviteCodeRow, + type InviteRole, +} from "../auth/invites"; + +// --------------------------------------------------------------------------- +// Handlers for the self-host admin (invite-code) API. Every Promise-returning +// boundary (Better Auth, the libSQL store) is wrapped in Effect.tryPromise with +// a typed failure — no raw try/catch, no Promise.catch. Each route is gated: +// the caller must be an owner/admin member of the one org (resolved through the +// org primitive's getActiveMember). +// --------------------------------------------------------------------------- + +const requestHeaders = Effect.map( + HttpServerRequest.HttpServerRequest.asEffect(), + (request): Headers => new Headers({ ...request.headers }), +); + +// Resolve + authorize the caller, returning their member record (for userId). +const requireAdmin = (headers: Headers) => + Effect.gen(function* () { + const { auth } = yield* BetterAuth; + const member = yield* Effect.tryPromise({ + try: () => auth.api.getActiveMember({ headers }), + catch: () => new AdminError({ message: "Failed to resolve session" }), + }).pipe(Effect.orElseSucceed(() => null)); + if (!member) return yield* new AdminUnauthorized(); + if (member.role !== "owner" && member.role !== "admin") return yield* new AdminForbidden(); + return member; + }); + +const narrowRole = (role: string | undefined): InviteRole => + role === "admin" ? "admin" : "member"; + +// Drop the internal audit columns (createdBy/usedBy) for the wire shape. +const toWire = (row: InviteCodeRow): typeof InviteCodeSchema.Type => ({ + id: row.id, + code: row.code, + role: row.role, + label: row.label, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + usedByEmail: row.usedByEmail, + usedAt: row.usedAt, +}); + +export const AdminHandlers = HttpApiBuilder.group(AdminHttpApi, "admin", (handlers) => + handlers + .handle("listInvites", () => + Effect.gen(function* () { + yield* requireAdmin(yield* requestHeaders); + const { client } = yield* SelfHostDb; + const rows = yield* Effect.tryPromise({ + try: () => listInviteCodes(client), + catch: () => new AdminError({ message: "Failed to list invites" }), + }); + return { invites: rows.map(toWire) }; + }), + ) + .handle("createInvite", ({ payload }) => + Effect.gen(function* () { + const member = yield* requireAdmin(yield* requestHeaders); + const { client } = yield* SelfHostDb; + const days = payload.expiresInDays ?? null; + const expiresAt = + days && days > 0 ? new Date(Date.now() + days * 86_400_000).toISOString() : null; + const row = yield* Effect.tryPromise({ + try: () => + createInviteCode(client, { + createdBy: member.userId, + role: narrowRole(payload.role), + label: payload.label?.trim() ? payload.label.trim() : null, + expiresAt, + }), + catch: () => new AdminError({ message: "Failed to create invite" }), + }); + return toWire(row); + }), + ) + .handle("revokeInvite", ({ params }) => + Effect.gen(function* () { + yield* requireAdmin(yield* requestHeaders); + const { client } = yield* SelfHostDb; + yield* Effect.tryPromise({ + try: () => revokeInviteCode(client, params.inviteId), + catch: () => new AdminError({ message: "Failed to revoke invite" }), + }); + return { success: true }; + }), + ), +); + +export interface SelfHostAdminApiDeps { + readonly betterAuth: BetterAuthHandle; + readonly db: SelfHostDbHandle; + readonly mountPrefix: `/${string}`; +} + +/** + * The mountable extension route layer: registers the admin routes on the + * `mountPrefix`-prefixed view of the ambient router (so `/admin/*` is served at + * `/api/admin/*`). Better Auth + the DB handle are app singletons, provided via + * `provideRequest` so the handlers' per-request requirement markers are cleared + * (a plain `Layer.provide` leaves them on the layer's requirement channel). The + * residual platform/router requirements are cleared by the serve binding — the + * loose `RouteExtension` channel the app's `extensions.routes` accepts. + */ +export const makeSelfHostAdminApiLayer = ({ + betterAuth, + db, + mountPrefix, +}: SelfHostAdminApiDeps) => { + const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), + ); + return HttpApiBuilder.layer(AdminHttpApi).pipe( + Layer.provide(AdminHandlers), + Layer.provide(prefixedRouter), + HttpRouter.provideRequest( + Layer.mergeAll(Layer.succeed(BetterAuth)(betterAuth), Layer.succeed(SelfHostDb)(db)), + ), + ); +}; diff --git a/apps/host-selfhost/src/admin/invites.node.test.ts b/apps/host-selfhost/src/admin/invites.node.test.ts new file mode 100644 index 000000000..1b20f2888 --- /dev/null +++ b/apps/host-selfhost/src/admin/invites.node.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// Real Better Auth path: signup must be invite-gated. +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-invite-")); +process.env.BETTER_AUTH_SECRET = "invite-test-secret-0123456789-abcdefghij-klmn"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@invite.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; + +const { makeSelfHostApiHandler } = await import("../app"); +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +const signUp = (body: Record) => + handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + +test("open signup is closed: a signup without a valid invite code is rejected", async () => { + const res = await signUp({ + email: "intruder@invite.test", + password: "password-12345678", + name: "Intruder", + }); + expect(res.status).not.toBe(200); + + const badCode = await signUp({ + email: "intruder2@invite.test", + password: "password-12345678", + name: "Intruder", + inviteCode: "AAAA-BBBB-CCCC", + }); + expect(badCode.status).not.toBe(200); +}); + +test("a code minted via the admin API redeems into a real org membership", async () => { + // Minted through the TYPED admin HttpApi client (see mint-invite.ts). + const inviteCode = await mintInviteCode(handler); + + const res = await signUp({ + email: "member@invite.test", + password: "password-12345678", + name: "Member", + inviteCode, + }); + expect(res.status).toBe(200); + const token = res.headers.get("set-auth-token") ?? ""; + expect(token).not.toBe(""); + + // The new user resolves to the one org's scope (membership, via the pin). + const scope = await handler( + new Request(`${BASE}/api/scope`, { headers: { authorization: `Bearer ${token}` } }), + ); + expect(scope.status).toBe(200); + + // The single-use code is now spent: reusing it is rejected. + const reuse = await signUp({ + email: "second@invite.test", + password: "password-12345678", + name: "Second", + inviteCode, + }); + expect(reuse.status).not.toBe(200); +}); diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts new file mode 100644 index 000000000..1401d0685 --- /dev/null +++ b/apps/host-selfhost/src/app.ts @@ -0,0 +1,130 @@ +import { HttpApiSwagger } from "effect/unstable/httpapi"; +import { HttpEffect, HttpRouter } from "effect/unstable/http"; +import { Layer } from "effect"; + +import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; + +import { resolveAuthProviders } from "./auth"; +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"; + +// =========================================================================== +// 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, + }); + + // ---- 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) ---------------- + const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth); + + 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)), + // 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, + 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, closeDb } = await makeSelfHostApp(options); + const web = toWebHandler(); + return { + handler: web.handler, + dispose: async () => { + await web.dispose(); + await closeDb(); + }, + }; +}; diff --git a/apps/host-selfhost/src/auth/better-auth.test.ts b/apps/host-selfhost/src/auth/better-auth.test.ts new file mode 100644 index 000000000..beef0c503 --- /dev/null +++ b/apps/host-selfhost/src/auth/better-auth.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// Real Better Auth path: set a secret + bootstrap admin before importing. +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-")); +process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; + +const { makeSelfHostApiHandler } = await import("../app"); + +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +test("migrations create both the Better Auth and FumaDB executor schema regions", async () => { + // Open a SEPARATE libSQL connection to the same file Better Auth (via its own + // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this + // connection can read Better Auth's tables AND rows proves the cross-connection + // invariant: there is no shared in-process handle anymore, yet a row Better + // Auth wrote is immediately visible here on the same file: URL. + const { createClient } = await import("@libsql/client"); + const db = createClient({ url: `file:${join(process.env.EXECUTOR_DATA_DIR!, "data.db")}` }); + const names = (await db.execute("SELECT name FROM sqlite_master WHERE type='table'")).rows.map( + // oxlint-disable-next-line executor/no-redundant-primitive-cast -- boundary: sqlite_master.name is TEXT; narrow libSQL's SQLValue to string for the table-name list + (r) => r.name as string, + ); + // Better Auth tables + for (const t of ["user", "session", "account", "organization", "member"]) { + expect(names).toContain(t); + } + // FumaDB executor tables coexist in the same file + expect(names).toContain("secret"); + + // CROSS-CONNECTION PROOF: the bootstrap admin Better Auth wrote through its + // LibsqlDialect connection is readable through this independent connection. + // oxlint-disable-next-line executor/no-double-cast -- boundary: the SELECT column is the schema contract for the Better Auth `user` row read off this independent libSQL connection + const admin = ( + await db.execute({ + sql: "SELECT email FROM user WHERE email = ?", + args: ["admin@test.local"], + }) + ).rows[0] as unknown as { email: string } | undefined; + expect(admin?.email).toBe("admin@test.local"); + db.close(); +}); + +test("sign-up issues a bearer token and resolves to a per-user org-pinned scope", async () => { + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "member@test.local", + password: "member-password-123", + name: "Member", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); + const token = signUp.headers.get("set-auth-token"); + expect(token).toBeTruthy(); + + const scoped = await handler( + new Request("http://localhost/api/scope", { headers: { authorization: `Bearer ${token}` } }), + ); + expect(scoped.status).toBe(200); + const body = (await scoped.json()) as { id: string; stack: ReadonlyArray<{ id: string }> }; + expect(body.stack.length).toBe(2); + const inner = body.stack[0]!; + const outer = body.stack[1]!; + expect(outer.id).toBe(body.id); + expect(inner.id.startsWith("user-org:")).toBe(true); + expect(inner.id.endsWith(`:${outer.id}`)).toBe(true); +}); + +test("an unauthenticated request is rejected with 401", async () => { + const res = await handler(new Request("http://localhost/api/scope")); + expect(res.status).toBe(401); +}); diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts new file mode 100644 index 000000000..578304090 --- /dev/null +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -0,0 +1,234 @@ +import { betterAuth, type BetterAuthOptions } from "better-auth"; +import { APIError } from "better-auth/api"; +import { admin, bearer, mcp, organization } from "better-auth/plugins"; +import { apiKey } from "@better-auth/api-key"; +import { type Client } from "@libsql/client"; +import { LibsqlDialect } from "@libsql/kysely-libsql"; +import { Context } from "effect"; + +import { loadConfig } from "../config"; +import { seedOrgAndAdmin } from "./seed"; +import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites"; + +// The self-service signup gate: present only on the live (phase-2) auth +// instance, so the bootstrap seed's `createUser` — which +// runs on the gate-free phase-1 instance — is never blocked. `getAuth` is +// late-bound because the hooks call `auth.api.addMember` AFTER the instance they +// belong to is constructed (the closure resolves it at request time). +interface SignupGate { + readonly client: Client; + readonly organizationId: string; + readonly getAuth: () => Auth | null; +} + +// Only self-service email signups are code-gated. Server/admin-initiated user +// creation (the seed, or a future admin "add user") flows through other paths. +const SIGNUP_PATH = "/sign-up/email"; + +// --------------------------------------------------------------------------- +// Better Auth instance over the SAME libSQL `file:` URL as the FumaDB executor +// tables ("one file, two schema regions"). +// +// Schema-at-boot: passing `{ dialect: new LibsqlDialect({ url }), type: "sqlite" }` +// makes Better Auth's createKyselyAdapter take its `"dialect" in db` branch (no +// native dep, no bun:sqlite); `runMigrations()` creates the auth tables +// idempotently in that file. `makeAuthOptions` is the single source of truth so +// the migrator and runtime instance never drift. +// +// CRITICAL: LibsqlDialect opens its OWN libSQL connection to the file — it does +// NOT share SelfHostDb's drizzle connection. Both target one file, and a row +// Better Auth writes via this dialect is immediately readable through the +// drizzle/FumaDB client (proven by seed.ts's reads + better-auth.test.ts). The +// per-connection foreign_keys/WAL PRAGMAs SelfHostDb set on its own connection +// do NOT carry to this one; for the auth tables that is fine (Kysely issues no +// FK-dependent reads at boot and WAL is already a file-level mode), and the +// shared file stays consistent because writes go through SQLite's file lock. +// +// NEVER call .destroy() on the resulting Kysely instance during normal +// operation — SelfHostDb owns the file lifecycle and closes its client at +// shutdown; the dialect's connection is GC'd with the auth instance. +// +// `satisfies BetterAuthOptions` (not a return annotation) keeps the literal +// plugin tuple so `betterAuth` infers the plugin-augmented `auth.api` and +// session/user shapes (activeOrganizationId, role, createUser, ...). +// --------------------------------------------------------------------------- + +const makeAuthOptions = (url: string, organizationId: string, gate?: SignupGate) => { + const config = loadConfig(); + // Always resolved (generated + persisted when no env is set); this guards only + // an explicitly-set env secret that is too weak. + const secret = config.authSecret; + if (secret.length < 32) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a multi-user auth server must not boot with a weak session secret + throw new Error("BETTER_AUTH_SECRET (or AUTH_SECRET), if set, must be at least 32 characters"); + } + return { + database: { dialect: new LibsqlDialect({ url }), type: "sqlite" as const }, + secret, + baseURL: config.webBaseUrl, + // The browser Origin must match this exactly; CLI/MCP bearer requests carry + // no Origin and are unaffected. + trustedOrigins: [config.webBaseUrl], + emailAndPassword: { enabled: true }, + // `apiKey` issues long-lived personal keys (the API-keys page). With + // `enableSessionForAPIKeys`, presenting a key resolves to its owner's + // session — so a key works as a Bearer token for the API + MCP endpoint. + // + // `mcp()` adds the MCP OAuth Authorization Server: dynamic client + // registration + authorize + token under /api/auth/mcp/*, the discovery + // docs, and `getMcpSession` (opaque-bearer validation). It WRAPS + // oidcProvider — do NOT also add oidcProvider. The two root well-known docs + // are re-emitted by the shared envelope (MCP clients probe the origin root, + // not the /api/auth basePath). + plugins: [ + organization(), + admin(), + apiKey({ enableSessionForAPIKeys: true }), + bearer(), + mcp({ loginPage: "/login" }), + ], + databaseHooks: { + session: { + create: { + // Single-org instance: pin every session to the one organization, so + // every authenticated user resolves to the org scope. + before: async (session: Record) => ({ + data: { ...session, activeOrganizationId: organizationId }, + }), + }, + }, + // The signup gate. First-run: an org with ZERO members is unclaimed, so + // the first signup is admitted ungated and becomes the owner. After that, + // `before` rejects a signup without a valid, unused, unexpired invite code + // and `after` makes the new user a real `member` + burns the code. + ...(gate + ? { + user: { + create: { + before: async (_user, context) => { + if (context?.path !== SIGNUP_PATH) return; + if (await orgHasNoMembers(gate)) return; // first user claims the org + const code = inviteCodeFrom(context); + if (!code) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a Better Auth create hook rejects a request by throwing APIError + throw new APIError("FORBIDDEN", { + message: "An invite code is required to sign up.", + }); + } + if (!(await findRedeemableCode(gate.client, code))) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a Better Auth create hook rejects a request by throwing APIError + throw new APIError("FORBIDDEN", { + message: "That invite code is invalid, already used, or expired.", + }); + } + }, + after: async (user, context) => { + if (context?.path !== SIGNUP_PATH) return; + const auth = gate.getAuth(); + if (!auth) return; + // First user into an empty org becomes its owner (no code). + if (await orgHasNoMembers(gate)) { + await auth.api.addMember({ + body: { userId: user.id, role: "owner", organizationId: gate.organizationId }, + }); + return; + } + const code = inviteCodeFrom(context); + if (!code) return; + const redeemable = await findRedeemableCode(gate.client, code); + if (!redeemable) return; + await auth.api.addMember({ + body: { + userId: user.id, + role: redeemable.role, + organizationId: gate.organizationId, + }, + }); + await consumeInviteCode(gate.client, code, { + usedBy: user.id, + usedByEmail: user.email, + }); + }, + }, + }, + } + : {}), + }, + } satisfies BetterAuthOptions; +}; + +// The invite code rides on the signup request body (`{ name, email, password, +// inviteCode }`); Better Auth reads the body loosely, so a non-schema field +// survives to the create hook's endpoint context. +const inviteCodeFrom = (context: { body?: unknown }): string | undefined => { + const body = context.body; + if (body && typeof body === "object" && "inviteCode" in body) { + const code = (body as { inviteCode?: unknown }).inviteCode; + if (typeof code === "string" && code.trim().length > 0) return code; + } + return undefined; +}; + +// Count org members via Better Auth's OWN adapter — the SAME connection that +// `addMember` writes through. SelfHostDb opens a SEPARATE libSQL connection +// whose snapshot can lag Better Auth's writes (observed under Bun: a just-added +// member is invisible to that connection for a while), so any membership read +// that gates behaviour MUST go through here to stay consistent with the writes. +export const countOrgMembers = (auth: Auth, organizationId: string): Promise => + auth.$context.then(({ adapter }) => + adapter.count({ model: "member", where: [{ field: "organizationId", value: organizationId }] }), + ); + +// True when the single org has no members yet — the unclaimed first-run state. +const orgHasNoMembers = async (gate: SignupGate): Promise => { + const auth = gate.getAuth(); + if (!auth) return true; + return (await countOrgMembers(auth, gate.organizationId)) === 0; +}; + +const createAuthInstance = (url: string, organizationId: string, gate?: SignupGate) => + betterAuth(makeAuthOptions(url, organizationId, gate)); + +export type Auth = ReturnType; + +export interface BetterAuthHandle { + readonly auth: Auth; + readonly organizationId: string; + readonly organizationName: string; + readonly handler: (request: Request) => Promise; +} + +export class BetterAuth extends Context.Service()( + "@executor-js/host-selfhost/BetterAuth", +) {} + +/** + * Build the Better Auth instance: migrate, seed the org+admin, then rebuild + * with the resolved org id pinned into the session hook. runMigrations and the + * seed are idempotent, so this is safe on every boot. + * + * `url` is the SAME libSQL `file:` URL SelfHostDb opened; `client` is + * SelfHostDb's drizzle connection to that file, used by the seed for its two + * idempotency reads against the auth tables Better Auth just migrated (proving + * the cross-connection invariant: Better Auth writes via LibsqlDialect are + * visible through SelfHostDb's client on the same file). + */ +export const buildBetterAuth = async (url: string, client: Client): Promise => { + const config = loadConfig(); + + // Phase 1: bootstrap instance (placeholder org, NO signup gate), create + // tables, seed. `runMigrations()` flows through the LibsqlDialect and is + // idempotent; the gate-free instance lets the seed's `createUser` through. + const bootstrap = createAuthInstance(url, ""); + await (await bootstrap.$context).runMigrations(); + await ensureInviteCodeTable(client); + const { organizationId, organizationName } = await seedOrgAndAdmin(bootstrap, client, config); + + // Phase 2: the live instance — real org id (session pin) + the signup gate. + // `getAuth` resolves to this very instance, so the gate's `after` hook can + // call `auth.api.addMember` once a code is redeemed. + let auth: Auth | null = null; + const gate: SignupGate = { client, organizationId, getAuth: () => auth }; + auth = createAuthInstance(url, organizationId, gate); + return { auth, organizationId, organizationName, handler: auth.handler }; +}; diff --git a/apps/host-selfhost/src/auth/identity.ts b/apps/host-selfhost/src/auth/identity.ts new file mode 100644 index 000000000..b75685b99 --- /dev/null +++ b/apps/host-selfhost/src/auth/identity.ts @@ -0,0 +1,84 @@ +import { Effect, Layer } from "effect"; + +import { IdentityProvider, Unauthorized } from "@executor-js/api/server"; + +import { BetterAuth } from "./better-auth"; + +// --------------------------------------------------------------------------- +// The self-host identity seam — the production implementation of the shared +// `IdentityProvider` from `@executor-js/api/server`, which resolves an incoming +// request to a Principal. WorkOS (cloud) and Better Auth (self-host) are +// interchangeable implementations of the same tag; nothing downstream knows +// which is wired. +// +// - succeeds with a Principal -> authenticated +// - fails Unauthorized -> no/invalid credential (renders 401) +// - fails NoOrganization -> valid credential, no org (renders 403) +// +// `betterAuthIdentityLayer` is the only production provider. The trivial fake +// identities tests inject live in `src/testing/test-app.ts`. +// --------------------------------------------------------------------------- + +const bearerToken = (headers: Headers): string | undefined => { + const authorization = headers.get("authorization"); + if (!authorization) return undefined; + return authorization.toLowerCase().startsWith("bearer ") + ? authorization.slice(7).trim() || undefined + : undefined; +}; + +// --------------------------------------------------------------------------- +// The production IdentityProvider: resolve a request to a Better Auth session +// and map it to a neutral Principal. Three credential shapes resolve here: +// - session cookie (browser SPA) +// - Bearer session token (bearer plugin) +// - Bearer API key — the apiKey plugin reads `x-api-key`, so when the normal +// resolution fails we retry with the Bearer value as x-api-key, which (with +// enableSessionForAPIKeys) mints the owner's session. This is what lets a +// generated API key authenticate the API + MCP endpoint as a Bearer token. +// Single-org instance, so organizationName is the boot-cached org name. +// --------------------------------------------------------------------------- + +export const betterAuthIdentityLayer: Layer.Layer = + Layer.effect(IdentityProvider)( + Effect.gen(function* () { + const { auth, organizationId, organizationName } = yield* BetterAuth; + return IdentityProvider.of({ + authenticate: (request) => + Effect.gen(function* () { + let resolved = yield* Effect.promise(() => + auth.api.getSession({ headers: request.headers }), + ); + if (!resolved) { + const token = bearerToken(request.headers); + if (token) { + resolved = yield* Effect.tryPromise({ + try: () => auth.api.getSession({ headers: { "x-api-key": token } }), + catch: () => "api-key session lookup failed", + }).pipe(Effect.orElseSucceed(() => null)); + } + } + // No session resolved from any credential shape -> unauthenticated. + // The middleware's failure strategy renders this as a 401. + if (!resolved) return yield* new Unauthorized(); + // Single-org instance: every authenticated user belongs to the one + // seeded org. Cookie/bearer-session logins are pinned to it by the + // session hook; API-key-minted sessions carry no active org, so we + // default to the seeded org rather than rejecting with NoOrganization. + const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId; + return { + accountId: resolved.user.id, + organizationId: resolvedOrganizationId, + organizationName, + email: resolved.user.email, + name: resolved.user.name ?? null, + avatarUrl: resolved.user.image ?? null, + roles: (resolved.user.role ?? "user") + .split(",") + .map((role) => role.trim()) + .filter((role) => role.length > 0), + }; + }), + }); + }), + ); diff --git a/apps/host-selfhost/src/auth/index.ts b/apps/host-selfhost/src/auth/index.ts new file mode 100644 index 000000000..63c2e81ae --- /dev/null +++ b/apps/host-selfhost/src/auth/index.ts @@ -0,0 +1,45 @@ +import { Layer } from "effect"; + +import { IdentityProvider } from "@executor-js/api/server"; + +import type { SelfHostDbHandle } from "../db/self-host-db"; +import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; +import { betterAuthIdentityLayer } from "./identity"; + +export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; +export { betterAuthIdentityLayer } from "./identity"; + +// --------------------------------------------------------------------------- +// Resolve the self-host auth providers. +// +// Build the Better Auth instance over the shared libSQL file, expose its +// `IdentityProvider` (cookie/bearer/api-key) and its web handler (mounted at +// /api/auth/*). Returns the live `BetterAuthHandle` so the composition root can +// build the account API and the Better Auth MCP OAuth seam. +// +// This is the one and only production auth path. Tests that need a fake identity +// (single-admin / header-driven) compose `ExecutorApp.make` directly through +// `makeSelfHostTestApp` (src/testing/test-app.ts) rather than passing through +// here, so this resolution is unconditional. +// --------------------------------------------------------------------------- + +export interface ResolvedAuthProviders { + /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */ + readonly identityLayer: Layer.Layer; + /** Better Auth's web handler (`/api/auth/*`). */ + readonly authHandler: (request: Request) => Promise; + /** The live Better Auth handle (account API + Better Auth MCP OAuth seam). */ + readonly betterAuth: BetterAuthHandle; +} + +export const resolveAuthProviders = async ( + dbHandle: SelfHostDbHandle, +): Promise => { + const betterAuth = await buildBetterAuth(dbHandle.url, dbHandle.client); + const betterAuthLayer = Layer.succeed(BetterAuth)(betterAuth); + return { + identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)), + authHandler: betterAuth.handler, + betterAuth, + }; +}; diff --git a/apps/host-selfhost/src/auth/invites.ts b/apps/host-selfhost/src/auth/invites.ts new file mode 100644 index 000000000..20fdd23c2 --- /dev/null +++ b/apps/host-selfhost/src/auth/invites.ts @@ -0,0 +1,153 @@ +import { randomBytes } from "node:crypto"; + +import type { Client, Row } from "@libsql/client"; + +// --------------------------------------------------------------------------- +// Invite codes — the join mechanism for a single-tenant instance. +// +// The instance closes open signup (the `user.create` gate in better-auth.ts) +// and lets people in ONLY by redeeming a per-user, single-use code. The code is +// the bearer credential: whoever holds it can self-register (with their own +// name/email/password) and lands as a real `member` of the one org. Unlike +// Better Auth's `invitation` table, a code is NOT bound to an email — the admin +// hands out a link, not an address. +// +// Stored in a raw libSQL table managed here (CREATE TABLE IF NOT EXISTS on +// boot), the same hand-rolled-SQL pattern the org/admin seed uses against the +// shared libSQL file. It is intentionally independent of both the fumadb +// versioned schema and Better Auth's migrator. +// --------------------------------------------------------------------------- + +export type InviteRole = "admin" | "member"; + +export interface InviteCodeRow { + readonly id: string; + readonly code: string; + readonly role: InviteRole; + readonly label: string | null; + readonly createdBy: string; + readonly createdAt: string; + readonly expiresAt: string | null; + readonly usedBy: string | null; + readonly usedByEmail: string | null; + readonly usedAt: string | null; +} + +// Unambiguous alphabet (no 0/O/1/I/l) so a code is easy to read and type. +const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +// 12 chars grouped as XXXX-XXXX-XXXX — easy to read aloud or paste. +const generateCode = (): string => { + const bytes = randomBytes(12); + const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]); + return [chars.slice(0, 4), chars.slice(4, 8), chars.slice(8, 12)] + .map((g) => g.join("")) + .join("-"); +}; + +const toRow = (raw: Row): InviteCodeRow => ({ + id: String(raw.id), + code: String(raw.code), + role: raw.role === "admin" ? "admin" : "member", + label: raw.label == null ? null : String(raw.label), + createdBy: String(raw.created_by), + createdAt: String(raw.created_at), + expiresAt: raw.expires_at == null ? null : String(raw.expires_at), + usedBy: raw.used_by == null ? null : String(raw.used_by), + usedByEmail: raw.used_by_email == null ? null : String(raw.used_by_email), + usedAt: raw.used_at == null ? null : String(raw.used_at), +}); + +export const ensureInviteCodeTable = async (client: Client): Promise => { + await client.execute(` + CREATE TABLE IF NOT EXISTS invite_code ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + role TEXT NOT NULL DEFAULT 'member', + label TEXT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + used_by TEXT, + used_by_email TEXT, + used_at TEXT + ) + `); +}; + +export interface CreateInviteCodeInput { + readonly createdBy: string; + readonly role?: InviteRole; + readonly label?: string | null; + readonly expiresAt?: string | null; +} + +export const createInviteCode = async ( + client: Client, + input: CreateInviteCodeInput, +): Promise => { + const row: InviteCodeRow = { + id: randomBytes(16).toString("hex"), + code: generateCode(), + role: input.role ?? "member", + label: input.label ?? null, + createdBy: input.createdBy, + createdAt: new Date().toISOString(), + expiresAt: input.expiresAt ?? null, + usedBy: null, + usedByEmail: null, + usedAt: null, + }; + await client.execute({ + sql: `INSERT INTO invite_code (id, code, role, label, created_by, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + args: [row.id, row.code, row.role, row.label, row.createdBy, row.createdAt, row.expiresAt], + }); + return row; +}; + +// Newest first; the admin page renders pending + used together. +export const listInviteCodes = async (client: Client): Promise => { + const result = await client.execute("SELECT * FROM invite_code ORDER BY created_at DESC"); + return result.rows.map(toRow); +}; + +// Revoke = delete a pending (unused) code. Used codes are kept as an audit row +// (their membership already exists); deleting one would not remove the member. +export const revokeInviteCode = async (client: Client, id: string): Promise => { + await client.execute({ + sql: "DELETE FROM invite_code WHERE id = ? AND used_at IS NULL", + args: [id], + }); +}; + +// A code is redeemable when it exists, is unused, and is unexpired. +export const findRedeemableCode = async ( + client: Client, + code: string, +): Promise => { + const result = await client.execute({ + sql: "SELECT * FROM invite_code WHERE code = ? AND used_at IS NULL", + args: [code.trim().toUpperCase()], + }); + const raw = result.rows[0]; + if (!raw) return null; + const row = toRow(raw); + if (row.expiresAt && Date.parse(row.expiresAt) < Date.now()) return null; + return row; +}; + +// Mark a code consumed. The `used_at IS NULL` guard makes this the single-use +// gate even under a race: rowsAffected === 0 means someone redeemed it first. +export const consumeInviteCode = async ( + client: Client, + code: string, + by: { usedBy: string; usedByEmail: string }, +): Promise => { + const result = await client.execute({ + sql: `UPDATE invite_code SET used_by = ?, used_by_email = ?, used_at = ? + WHERE code = ? AND used_at IS NULL`, + args: [by.usedBy, by.usedByEmail, new Date().toISOString(), code.trim().toUpperCase()], + }); + return result.rowsAffected > 0; +}; diff --git a/apps/host-selfhost/src/auth/seed.ts b/apps/host-selfhost/src/auth/seed.ts new file mode 100644 index 000000000..d93d3ae6e --- /dev/null +++ b/apps/host-selfhost/src/auth/seed.ts @@ -0,0 +1,79 @@ +import { randomBytes } from "node:crypto"; + +import type { Client } from "@libsql/client"; + +import type { SelfHostConfig } from "../config"; +import type { Auth } from "./better-auth"; + +// --------------------------------------------------------------------------- +// Idempotent first-boot bootstrap: ensure the single organization and a +// bootstrap admin exist. Uses server-side auth.api calls (no session, no CLI) +// and queries the freshly-migrated Better Auth tables directly (through +// SelfHostDb's libSQL client — the SAME file Better Auth migrated, proving the +// cross-connection invariant) to stay idempotent across restarts. Returns the +// resolved org id/name, which the session-pin hook and the AuthProvider's +// org-name cache read. +// --------------------------------------------------------------------------- + +export const seedOrgAndAdmin = async ( + auth: Auth, + client: Client, + config: SelfHostConfig, +): Promise<{ organizationId: string; organizationName: string }> => { + // Idempotent: once the single organization exists, boot is past first-run. + // oxlint-disable-next-line executor/no-double-cast -- boundary: the SELECT columns are the schema contract for the Better Auth `organization` row read off the libSQL client + const existingOrg = ( + await client.execute({ + sql: "SELECT id, name FROM organization WHERE slug = ?", + args: [config.orgSlug], + }) + ).rows[0] as unknown as { id: string; name: string } | undefined; + if (existingOrg) { + return { organizationId: existingOrg.id, organizationName: existingOrg.name }; + } + + // Headless bootstrap: when BOTH admin email and password are set, pre-create + // that admin as the org owner (CI / infra-as-code). Otherwise fall through to + // the turnkey path so the first browser visitor claims the instance. + if (config.bootstrapAdminEmail && config.bootstrapAdminPassword) { + // oxlint-disable-next-line executor/no-double-cast -- boundary: the SELECT column is the schema contract for the Better Auth `user` row read off the libSQL client + const existingUser = ( + await client.execute({ + sql: "SELECT id FROM user WHERE email = ?", + args: [config.bootstrapAdminEmail], + }) + ).rows[0] as unknown as { id: string } | undefined; + let adminId = existingUser?.id; + if (!adminId) { + const created = await auth.api.createUser({ + body: { + email: config.bootstrapAdminEmail, + password: config.bootstrapAdminPassword, + name: config.bootstrapAdminName, + role: "admin", + }, + }); + adminId = created.user.id; + } + // Pass userId so the org is created with no session and the admin becomes + // its owner (creates the membership row). + const org = await auth.api.createOrganization({ + body: { name: config.organizationName, slug: config.orgSlug, userId: adminId }, + }); + if (!org) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: org creation must succeed for a usable instance + throw new Error("Failed to create the bootstrap organization"); + } + return { organizationId: org.id, organizationName: config.organizationName }; + } + + // Turnkey first-run: create the single organization with NO members. The + // first person to open the app signs up ungated and becomes the owner (the + // signup gate enforces this — an org with zero members is unclaimed). + const organizationId = randomBytes(16).toString("hex"); + await client.execute({ + sql: "INSERT INTO organization (id, name, slug, createdAt) VALUES (?, ?, ?, ?)", + args: [organizationId, config.organizationName, config.orgSlug, new Date().toISOString()], + }); + return { organizationId, organizationName: config.organizationName }; +}; diff --git a/apps/host-selfhost/src/boot.test.ts b/apps/host-selfhost/src/boot.test.ts new file mode 100644 index 000000000..4a29bc09a --- /dev/null +++ b/apps/host-selfhost/src/boot.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +// Config reads the environment, so point it at a throwaway data dir before +// importing the app graph. +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-boot-")); + +const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app"); + +const { handler, dispose } = await makeSelfHostTestApp({ + identity: singleAdminIdentityLayer({ + userId: "admin", + organizationId: "default-org", + organizationName: "Default", + }), +}); +afterAll(() => dispose()); + +test("GET /scope returns the single-admin org scope stack", async () => { + const res = await handler(new Request("http://localhost/api/scope")); + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string; stack: ReadonlyArray<{ id: string }> }; + expect(body.id).toBe("default-org"); + expect(body.stack.map((s) => s.id)).toEqual(["user-org:admin:default-org", "default-org"]); +}); + +test("POST /executions runs code in the QuickJS sandbox", async () => { + const res = await handler( + new Request("http://localhost/api/executions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: "export default 6 * 7" }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; text: string; isError: boolean }; + expect(body.status).toBe("completed"); + expect(body.text).toBe("42"); + expect(body.isError).toBe(false); +}); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts new file mode 100644 index 000000000..fea08745c --- /dev/null +++ b/apps/host-selfhost/src/config.ts @@ -0,0 +1,119 @@ +import { randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// --------------------------------------------------------------------------- +// Self-host server config — a single typed surface parsed from the +// environment. Slice 1 keeps this a plain loader with safe defaults; it can +// graduate to Effect-Schema validation without changing call sites. +// --------------------------------------------------------------------------- + +export const SELF_HOST_NAMESPACE = "executor_selfhost"; +export const SELF_HOST_SCHEMA_VERSION = "1.0.0"; + +export interface SelfHostConfig { + /** Bind address. Defaults to loopback. */ + readonly host: string; + readonly port: number; + /** Absolute path to the SQLite database file. */ + readonly dbPath: string; + /** Public base URL used by core tools that build absolute links. */ + readonly webBaseUrl: string; + /** + * Whether sandboxed code may reach loopback/private network addresses. + * Defaults to false — adversarial LLM code should not hit the host's + * internal network unless an operator opts in. + */ + readonly allowLocalNetwork: boolean; + // Better Auth session secret. Always resolved (env, else generated + persisted + // under the data dir) so a single-container deploy boots with no env; the auth + // layer still validates an explicitly-set env secret is long enough. + readonly authSecret: string; + readonly bootstrapAdminEmail: string | undefined; + readonly bootstrapAdminPassword: string | undefined; + readonly bootstrapAdminName: string; + /** The single organization every self-host user belongs to. */ + readonly organizationName: string; + readonly orgSlug: string; +} + +export const resolveDataDir = (): string => + process.env.EXECUTOR_DATA_DIR ?? join(process.cwd(), ".executor-selfhost"); + +let cachedSecretKey: string | undefined; + +/** + * Master key for the encrypted secret provider. Prefers EXECUTOR_SECRET_KEY; + * otherwise generates and persists a random key under the data dir on first + * boot (so a single-container deploy is encrypted-by-default without manual + * setup). Memoized so repeated per-request reads are cheap. + */ +export const resolveSecretKey = (): string => { + if (cachedSecretKey) return cachedSecretKey; + const fromEnv = process.env.EXECUTOR_SECRET_KEY?.trim(); + if (fromEnv) { + cachedSecretKey = fromEnv; + return fromEnv; + } + const keyPath = join(resolveDataDir(), "secret.key"); + if (existsSync(keyPath)) { + cachedSecretKey = readFileSync(keyPath, "utf8").trim(); + return cachedSecretKey; + } + mkdirSync(resolveDataDir(), { recursive: true }); + const generated = randomBytes(32).toString("base64"); + writeFileSync(keyPath, generated, { mode: 0o600 }); + console.warn( + `[executor] generated a secret-encryption key at ${keyPath}. Set EXECUTOR_SECRET_KEY to manage it explicitly (and to keep secrets readable across data-dir changes).`, + ); + cachedSecretKey = generated; + return generated; +}; + +let cachedAuthSecret: string | undefined; + +/** + * Better Auth session secret. Prefers BETTER_AUTH_SECRET / AUTH_SECRET; + * otherwise generates and persists a strong random secret under the data dir on + * first boot (so a single-container deploy boots with no env and keeps sessions + * valid across restarts). Memoized; mirrors {@link resolveSecretKey}. + */ +export const resolveAuthSecret = (): string => { + if (cachedAuthSecret) return cachedAuthSecret; + const fromEnv = (process.env.BETTER_AUTH_SECRET ?? process.env.AUTH_SECRET)?.trim(); + if (fromEnv) { + cachedAuthSecret = fromEnv; + return fromEnv; + } + const keyPath = join(resolveDataDir(), "auth-secret.key"); + if (existsSync(keyPath)) { + cachedAuthSecret = readFileSync(keyPath, "utf8").trim(); + return cachedAuthSecret; + } + mkdirSync(resolveDataDir(), { recursive: true }); + const generated = randomBytes(32).toString("base64"); + writeFileSync(keyPath, generated, { mode: 0o600 }); + console.warn( + `[executor] generated a session secret at ${keyPath}. Set BETTER_AUTH_SECRET to manage it explicitly (rotating it signs everyone out).`, + ); + cachedAuthSecret = generated; + return generated; +}; + +export const loadConfig = (): SelfHostConfig => { + const port = Number.parseInt(process.env.PORT ?? "4788", 10); + const dataDir = resolveDataDir(); + return { + host: process.env.EXECUTOR_HOST ?? "127.0.0.1", + port, + dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), + webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${port}`, + allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", + authSecret: resolveAuthSecret(), + bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, + bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, + bootstrapAdminName: process.env.EXECUTOR_BOOTSTRAP_ADMIN_NAME ?? "Admin", + organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default", + orgSlug: process.env.EXECUTOR_ORG_SLUG ?? "default", + }; +}; diff --git a/apps/host-selfhost/src/db/self-host-db.ts b/apps/host-selfhost/src/db/self-host-db.ts new file mode 100644 index 000000000..b595c8a56 --- /dev/null +++ b/apps/host-selfhost/src/db/self-host-db.ts @@ -0,0 +1,178 @@ +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { createClient, type Client } from "@libsql/client"; +import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; +import { type FumaDB } from "fumadb"; +import { + createDrizzleRuntimeSchemaFromTables, + ensureDrizzleRuntimeSchemaFromTables, +} from "fumadb/adapters/drizzle"; +import { type schema as fumaSchema, type RelationsMap } from "fumadb/schema"; +import { Context, Effect, Layer } from "effect"; + +import { + collectTables, + createExecutorFumaDb, + DbProvider, + type ExecutorDbHandle, +} from "@executor-js/api/server"; +import type { FumaDb, FumaTables } from "@executor-js/sdk"; + +import { SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; + +// --------------------------------------------------------------------------- +// SQLite executor DB factory, inline (like apps/local's sqlite-fumadb.ts and +// apps/cloud's fuma.ts — each app owns its DB wiring; there is no shared +// storage package). Differences from apps/local: busy_timeout + synchronous +// pragmas for the multi-user HTTP server, and the idempotent +// `ensureDrizzleRuntimeSchemaFromTables` schema-ensure (the drizzle adapter +// has no versioned migrator). Built ONCE for the process; the per-request +// executor reuses this long-lived handle's `db`. +// +// Driver: libSQL (@libsql/client + drizzle-orm/libsql), not bun:sqlite, so the +// self-host server runs on Node AND Bun (and the same code path serves edge by +// swapping the `file:` URL for an https Turso URL). Better Auth opens its OWN +// libSQL connection (LibsqlDialect) to the SAME file: URL — see better-auth.ts. +// Because libSQL connections are NOT a single shared in-process handle the way +// bun:sqlite's was, the WAL/busy_timeout/synchronous/foreign_keys PRAGMAs are +// re-applied PER connection (here, and again in the Better Auth dialect path). +// --------------------------------------------------------------------------- + +/** + * Build a `file:` libSQL URL from a filesystem path. libSQL requires an + * absolute path for `file:` URLs; `:memory:` passes through unchanged. + */ +export const toLibsqlFileUrl = (path: string): string => + path === ":memory:" ? path : `file:${resolve(path)}`; + +type SelfHostFumaSchema = ReturnType< + typeof fumaSchema> +>; + +export interface SelfHostDbHandle { + readonly db: FumaDb>; + readonly fuma: FumaDB[]>; + readonly drizzle: LibSQLDatabase>; + /** + * The libSQL client for this handle's `file:` URL. Better Auth opens its own + * separate connection to the same file via LibsqlDialect; the seed reads + * Better Auth's tables through this client (async), so the URL is carried + * alongside so callers can hand it to the dialect. + */ + readonly client: Client; + readonly url: string; + readonly close: () => Promise; +} + +export interface CreateSqliteExecutorDbOptions { + readonly tables: TTables; + readonly namespace: string; + readonly version?: string; + readonly path: string; +} + +export const createSqliteExecutorDb = async ( + options: CreateSqliteExecutorDbOptions, +): Promise> => { + const version = options.version ?? SELF_HOST_SCHEMA_VERSION; + if (options.path !== ":memory:") { + mkdirSync(dirname(options.path), { recursive: true }); + } + + const url = toLibsqlFileUrl(options.path); + const client = createClient({ url }); + // PER-CONNECTION PRAGMAs: libSQL gives drizzle and Better Auth SEPARATE + // connections to this file (no single shared handle), so these must be set on + // this connection here and again on Better Auth's dialect connection. WAL is a + // file-level mode once any connection enables it; foreign_keys is strictly + // per-connection and MUST be re-set on each. + await client.execute("PRAGMA foreign_keys = ON"); + await client.execute("PRAGMA journal_mode = WAL"); + // Survive concurrent writes from the multi-user HTTP server, and trade + // fsync-per-commit for fsync-per-checkpoint (durable under WAL). + await client.execute("PRAGMA busy_timeout = 5000"); + await client.execute("PRAGMA synchronous = NORMAL"); + + const schema = createDrizzleRuntimeSchemaFromTables({ + tables: options.tables, + namespace: options.namespace, + version, + provider: "sqlite", + }); + const drizzleDb = drizzle({ client, schema }); + + await ensureDrizzleRuntimeSchemaFromTables(drizzleDb, { + tables: options.tables, + namespace: options.namespace, + version, + provider: "sqlite", + }); + + const { db, fuma } = createExecutorFumaDb(drizzleDb, { + tables: options.tables, + namespace: options.namespace, + version, + provider: "sqlite", + }); + + return { + db, + fuma, + drizzle: drizzleDb, + client, + url, + close: async () => { + client.close(); + }, + }; +}; + +// --------------------------------------------------------------------------- +// Long-lived DB layer. Built once at boot; the connection lives for the +// process. The per-request executor (execution.ts) reuses this handle's `db` +// and only varies the scope stack — so "build once, rebind scope per request" +// is cheap. +// --------------------------------------------------------------------------- + +export class SelfHostDb extends Context.Service()( + "@executor-js/host-selfhost/SelfHostDb", +) {} + +export interface SelfHostDbLayerOptions { + readonly path: string; + readonly namespace?: string; + readonly version?: string; +} + +/** + * Open the self-host DB with the full plugin table set. Used both by the layer + * and by the composition root (which needs the raw handle eagerly so Better + * Auth can open its own libSQL connection to the same `file:` URL). + */ +export const createSelfHostDb = (options: SelfHostDbLayerOptions): Promise => + createSqliteExecutorDb({ + tables: collectTables(), + namespace: options.namespace ?? SELF_HOST_NAMESPACE, + version: options.version ?? SELF_HOST_SCHEMA_VERSION, + path: options.path, + }); + +// Shared DbProvider seam (P2a). The self-host handle keeps its libSQL driver, +// WAL/busy_timeout PRAGMAs, and the idempotent +// `ensureDrizzleRuntimeSchemaFromTables` bring-up; this just re-exposes the +// already-built long-lived handle under the shared `DbProvider` tag so the +// future shared `makeScopedExecutor` (P3) reads from one injection point. The +// release is owned by `SelfHostDb`, so this projection does not re-close. +export const SelfHostDbProvider: Layer.Layer = Layer.effect( + DbProvider, +)( + Effect.map( + SelfHostDb.asEffect(), + (handle): ExecutorDbHandle => ({ + db: handle.db, + fuma: handle.fuma, + close: handle.close, + }), + ), +); diff --git a/apps/host-selfhost/src/execution.ts b/apps/host-selfhost/src/execution.ts new file mode 100644 index 000000000..cf3dc1f57 --- /dev/null +++ b/apps/host-selfhost/src/execution.ts @@ -0,0 +1,80 @@ +import { Layer } from "effect"; + +import { + CodeExecutorProvider, + DbProvider, + EngineDecorator, + EngineDecoratorNoop, + HostConfig, + PluginsProvider, +} from "@executor-js/api/server"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; + +import executorConfig from "../executor.config"; +import { SelfHostDb, SelfHostDbProvider } from "./db/self-host-db"; +import { loadConfig } from "./config"; + +// --------------------------------------------------------------------------- +// Self-host execution-stack seams. +// +// The shared `makeExecutionStack` (@executor-js/api/server) owns the body: +// makeScopedExecutor -> createExecutionEngine -> EngineDecorator.decorate. +// Self-host just supplies the five seam Layers it reads from. Differences from +// cloud: the QuickJS in-process code substrate (vs the Cloudflare dynamic +// worker) and a NO-OP engine decorator (no usage metering). +// +// - DbProvider -> SelfHostDbProvider: projects the long-lived +// libSQL handle (built once at boot, see db/). The +// shared factory reads `db` per request without +// caching, so the long-lived lifetime is preserved. +// - PluginsProvider -> fresh `executor.config.ts#plugins()` per call, +// matching per-request plugin instances (avoids +// cross-request plugin state). +// - HostConfig -> `{ allowLocalNetwork, webBaseUrl }` from +// `loadConfig()`. +// - CodeExecutorProvider -> `makeQuickJsExecutor()`. +// - EngineDecorator -> no-op (self-host does not meter executions). +// --------------------------------------------------------------------------- + +export { makeExecutionStack } from "@executor-js/api/server"; + +export const SelfHostPluginsProvider: Layer.Layer = Layer.succeed(PluginsProvider)( + { + plugins: () => executorConfig.plugins(), + }, +); + +export const SelfHostHostConfig: Layer.Layer = Layer.sync(HostConfig, () => { + const config = loadConfig(); + return { + allowLocalNetwork: config.allowLocalNetwork, + webBaseUrl: config.webBaseUrl, + }; +}); + +export const SelfHostCodeExecutorProvider: Layer.Layer = Layer.sync( + CodeExecutorProvider, + () => makeQuickJsExecutor(), +); + +/** + * The `makeScopedExecutor` seams (`DbProvider` + `PluginsProvider` + + * `HostConfig`) over the long-lived `SelfHostDb`. Shared between the production + * `SelfHostExecutionStackLayer` and the `makeScopedExecutor` test entrypoint. + */ +export const SelfHostScopedExecutorSeams: Layer.Layer< + DbProvider | PluginsProvider | HostConfig, + never, + SelfHostDb +> = Layer.mergeAll(SelfHostDbProvider, SelfHostPluginsProvider, SelfHostHostConfig); + +/** + * The five execution-stack seams the shared `makeExecutionStack` reads from, + * bundled into one Layer. Requires the long-lived `SelfHostDb` (provided once at + * boot); the per-request executor only varies the scope stack. + */ +export const SelfHostExecutionStackLayer: Layer.Layer< + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator, + never, + SelfHostDb +> = Layer.mergeAll(SelfHostScopedExecutorSeams, SelfHostCodeExecutorProvider, EngineDecoratorNoop); diff --git a/apps/host-selfhost/src/first-run.node.test.ts b/apps/host-selfhost/src/first-run.node.test.ts new file mode 100644 index 000000000..861d993f4 --- /dev/null +++ b/apps/host-selfhost/src/first-run.node.test.ts @@ -0,0 +1,75 @@ +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +// Fully zero-config boot: NO BETTER_AUTH_SECRET and NO bootstrap admin env, so +// the secret is generated + persisted and the org is created with no members — +// the turnkey first-run path. +const DATA_DIR = mkdtempSync(join(tmpdir(), "eh-firstrun-")); +process.env.EXECUTOR_DATA_DIR = DATA_DIR; +delete process.env.BETTER_AUTH_SECRET; +delete process.env.AUTH_SECRET; +delete process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL; +delete process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD; + +const { makeSelfHostApiHandler } = await import("./app"); +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; +const get = (path: string) => handler(new Request(`${BASE}${path}`)); +const signUp = (body: Record) => + handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + +test("zero-config boot generates and persists a session secret in the data dir", () => { + expect(existsSync(join(DATA_DIR, "auth-secret.key"))).toBe(true); +}); + +test("health endpoint reports ok", async () => { + const res = await get("/api/health"); + expect(res.status).toBe(200); + expect(((await res.json()) as { status: string }).status).toBe("ok"); +}); + +test("a fresh instance needs setup, admits the first signup as owner, then gates the rest", async () => { + // Before anyone signs up, the org has zero members. + const before = await get("/api/setup-status"); + expect(before.status).toBe(200); + expect(((await before.json()) as { needsSetup: boolean }).needsSetup).toBe(true); + + // The first signup needs NO invite code and claims the org. + const first = await signUp({ + email: "owner@firstrun.test", + password: "password-12345678", + name: "Owner", + }); + expect(first.status).toBe(200); + const token = first.headers.get("set-auth-token") ?? ""; + expect(token).not.toBe(""); + + // Setup is now complete. + const after = await get("/api/setup-status"); + expect(((await after.json()) as { needsSetup: boolean }).needsSetup).toBe(false); + + // The first user is the owner: the admin API admits them. + const invites = await handler( + new Request(`${BASE}/api/admin/invites`, { headers: { authorization: `Bearer ${token}` } }), + ); + expect(invites.status).toBe(200); + + // A second signup with no code is now rejected — the invite gate is in force. + const second = await signUp({ + email: "intruder@firstrun.test", + password: "password-12345678", + name: "Intruder", + }); + expect(second.status).not.toBe(200); +}); diff --git a/apps/host-selfhost/src/index.ts b/apps/host-selfhost/src/index.ts new file mode 100644 index 000000000..ee92a0b34 --- /dev/null +++ b/apps/host-selfhost/src/index.ts @@ -0,0 +1,9 @@ +export { startServer } from "./serve"; +export { + makeSelfHostApp, + makeSelfHostApiHandler, + type SelfHostApiHandler, + type MakeSelfHostAppOptions, +} from "./app"; +export { loadConfig, type SelfHostConfig } from "./config"; +export { BetterAuth, buildBetterAuth, betterAuthIdentityLayer } from "./auth"; diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts new file mode 100644 index 000000000..31c435234 --- /dev/null +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -0,0 +1,180 @@ +import { Effect, Layer } from "effect"; +import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins"; + +import { IdentityProvider } from "@executor-js/api/server"; +import { + authenticated, + McpAuthProvider, + unauthorized, + type AuthOutcome, + type McpDiscoveryRoute, + type Principal, +} from "@executor-js/host-mcp"; + +import { BetterAuth } from "../auth/better-auth"; + +// --------------------------------------------------------------------------- +// Self-host McpAuthProvider adapter, backed by Better Auth's mcp() plugin. +// +// Responsibilities the envelope needs: +// +// 1. DECLARE the discovery routes it owns. MCP clients probe the true origin +// ROOT, but Better Auth's handler only mounts the well-known docs under +// /api/auth/.well-known/*, so we re-emit BOTH docs at the bare origin root +// via the plugin's helpers. The envelope registers a GET for each declared +// path. +// +// 2. `resourceMetadataUrl(request)` — the absolute `resource_metadata` URL the +// 401 challenge points at: the bare origin-root protected-resource doc +// (`/.well-known/oauth-protected-resource`). +// +// 3. `authenticate(request)` resolving an MCP principal as a typed AuthOutcome, +// trying two credential shapes in order: +// a. The mcp() OAuth opaque bearer (getMcpSession) — ONLY when an +// `Authorization: Bearer …` header is present (avoids a getMcpSession +// round-trip on every cookie request). getMcpSession does NOT validate +// `accessTokenExpiresAt`, so we ENFORCE expiry ourselves before +// accepting it, then enrich the bare {userId} into a full principal. +// b. The existing IdentityProvider path (session cookie / bearer-session / +// x-api-key) — preserves API-key Bearer access for the API + MCP. +// Anything that fails or yields nothing collapses to `Unauthorized`; the +// envelope renders the 401 + challenge. Self-host always has an org, so it +// never returns Forbidden/Unavailable. +// +// The OAuth endpoints themselves (/api/auth/mcp/{register,authorize,token}) +// stay on the Better Auth handler mounted at /api/auth — NOT in this seam. +// --------------------------------------------------------------------------- + +const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; +const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; + +const parseRoles = (role: string | null | undefined): ReadonlyArray => + (role ?? "user") + .split(",") + .map((r) => r.trim()) + .filter((r) => r.length > 0); + +/** + * The admin plugin's `role` column is populated at runtime but isn't part of + * Better Auth's static base-user type, so read it through a single typed view. + */ +const userRole = (user: object): string | null => { + const role = (user as { readonly role?: unknown }).role; + return typeof role === "string" ? role : null; +}; + +const hasBearer = (request: Request): boolean => + (request.headers.get("authorization") ?? "").startsWith("Bearer "); + +/** + * Absolute protected-resource metadata URL for the 401 challenge. Derive the + * origin from `baseURL` when set; otherwise from the live request so the URL is + * never relative (cloud-drop-in: a self-host behind any host resolves right). + */ +const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): string => { + const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; + return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`; +}; + +export const selfHostMcpAuth: Layer.Layer = + Layer.effect( + McpAuthProvider, + Effect.gen(function* () { + const { auth, organizationId, organizationName } = yield* BetterAuth; + const fallback = yield* IdentityProvider; + + const asMetadata = oAuthDiscoveryMetadata(auth); + const prMetadata = oAuthProtectedResourceMetadata(auth); + + const baseURL = auth.options.baseURL; + const resourceMetadataUrl = (request: Request): string => + resourceMetadataUrlFor(baseURL, request); + + // RFC 9728 challenge string carried on the Unauthorized outcome. Same shape + // as the envelope's default; we supply it explicitly to keep the 401's + // `WWW-Authenticate` fully owned by the provider. + const challengeFor = (request: Request): string => + `Bearer resource_metadata="${resourceMetadataUrl(request)}"`; + + const discoveryRoutes: ReadonlyArray = [ + { + path: PROTECTED_RESOURCE_METADATA_PATH, + handler: (request) => Effect.promise(() => prMetadata(request)), + }, + { + path: AUTHORIZATION_SERVER_METADATA_PATH, + handler: (request) => Effect.promise(() => asMetadata(request)), + }, + ]; + + // Resolved once; `internalAdapter.findUserById` enriches an OAuth userId. + const context = yield* Effect.promise(() => auth.$context); + + /** Enrich a bare OAuth `userId` into the full provider-neutral principal. */ + const principalFromUserId = (userId: string): Effect.Effect => + Effect.gen(function* () { + const user = yield* Effect.promise(() => context.internalAdapter.findUserById(userId)); + if (!user) return null; + return { + accountId: user.id, + // Single-org self-host: OAuth tokens carry no active org, so pin to + // the seeded org (same default as the cookie/api-key path). + organizationId, + organizationName, + email: user.email ?? "", + name: user.name ?? null, + avatarUrl: user.image ?? null, + roles: parseRoles(userRole(user)), + } satisfies Principal; + }); + + /** (a) The mcp() OAuth opaque bearer, with self-enforced expiry. */ + const authenticateOAuthBearer = (request: Request): Effect.Effect => + Effect.gen(function* () { + const session = yield* Effect.promise(() => + auth.api.getMcpSession({ headers: request.headers }), + ); + if (!session) return null; + // GOTCHA: getMcpSession does NOT validate accessTokenExpiresAt — an + // expired token still resolves. Reject it here. + if (new Date(session.accessTokenExpiresAt).getTime() < Date.now()) return null; + return yield* principalFromUserId(session.userId); + }).pipe(Effect.orElseSucceed(() => null)); + + /** (b) The existing cookie / bearer-session / x-api-key path. The fallback's + * api `Principal` shape is byte-identical to host-mcp's `Principal`. */ + const authenticateSession = (request: Request): Effect.Effect => + fallback.authenticate(request).pipe( + Effect.catchTags({ + Unauthorized: () => Effect.succeed(null), + NoOrganization: () => Effect.succeed(null), + }), + ); + + /** + * Try the OAuth bearer ONLY when a Bearer header is present (no + * getMcpSession round-trip on cookie requests), then the cookie/api-key + * fallback. Self-host always pins an org, so the outcome is always + * Authenticated or Unauthorized. + */ + const authenticate = (request: Request): Effect.Effect => + (hasBearer(request) + ? authenticateOAuthBearer(request).pipe( + Effect.flatMap((principal) => + principal ? Effect.succeed(principal) : authenticateSession(request), + ), + ) + : authenticateSession(request) + ).pipe( + Effect.map((principal) => + principal ? authenticated(principal) : unauthorized(challengeFor(request)), + ), + ); + + return { + discoveryRoutes, + resourceMetadataUrl, + authenticate, + }; + }), + ); diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts new file mode 100644 index 000000000..f6b212c87 --- /dev/null +++ b/apps/host-selfhost/src/mcp/index.ts @@ -0,0 +1,78 @@ +import { Layer } from "effect"; + +import { IdentityProvider } from "@executor-js/api/server"; +import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; + +import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; +import type { SelfHostDbHandle } from "../db/self-host-db"; +import { selfHostMcpAuth } from "./auth"; +import { + makeSelfHostMcpSessionStore, + selfHostMcpReporter, + selfHostMcpSessions, +} from "./session-store"; + +export { selfHostMcpAuth } from "./auth"; +export { + makeSelfHostMcpSessionStore, + selfHostMcpReporter, + selfHostMcpSessions, + McpEngineBuildError, +} from "./session-store"; + +// --------------------------------------------------------------------------- +// The self-host MCP serving seams, fed to `ExecutorApp.make`'s `mcp` group. +// +// `ExecutorApp.make` mounts the shared, provider-neutral MCP serving envelope +// from @executor-js/host-mcp (the two root OAuth discovery docs + the multi-user +// /mcp endpoint, top-level per the ecosystem convention). The envelope does its +// own auth + session handling and is mounted OUTSIDE the API's execution +// middleware, like /api/auth. +// +// Self-host provides the TWO envelope seams plus an error-reporter override: +// - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still +// requires `IdentityProvider`, which `make` provides from +// the resolved identity seam. +// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns +// dispatch (create + forward + ownership) and builds its +// engine internally over the shared SelfHostDb. +// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the +// host's console capture. +// +// The OAuth endpoints (/api/auth/mcp/{register,authorize,token}) stay on the +// Better Auth handler mounted at /api/auth — not in the envelope. +// --------------------------------------------------------------------------- + +export interface SelfHostMcpSeams { + /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */ + readonly auth: Layer.Layer; + /** The in-process session store seam (dispatch + lifetime). */ + readonly sessions: Layer.Layer; + /** Route 500 defects through the host's console `ErrorCapture`. */ + readonly reporter: Layer.Layer; + /** Dispose all live in-process MCP sessions at shutdown (not a seam). */ + readonly close: () => Promise; +} + +/** + * Build the self-host MCP serving seams over the long-lived DB handle. The auth + * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth + * instance provided; it still requires `IdentityProvider` from the resolved + * identity seam. Returns the three seam Layers plus the `close()` lifetime hook + * the app wires into shutdown. + */ +export const makeSelfHostMcpSeams = ( + dbHandle: SelfHostDbHandle, + betterAuth: BetterAuthHandle, +): SelfHostMcpSeams => { + const sessionStore = makeSelfHostMcpSessionStore(dbHandle); + const auth: Layer.Layer = selfHostMcpAuth.pipe( + Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), + ); + return { + auth, + sessions: selfHostMcpSessions(sessionStore), + reporter: selfHostMcpReporter, + close: sessionStore.close, + }; +}; diff --git a/apps/host-selfhost/src/mcp/mcp-oauth.test.ts b/apps/host-selfhost/src/mcp/mcp-oauth.test.ts new file mode 100644 index 000000000..3ef26cffa --- /dev/null +++ b/apps/host-selfhost/src/mcp/mcp-oauth.test.ts @@ -0,0 +1,163 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "../testing/mint-invite"; + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-env-")); +process.env.BETTER_AUTH_SECRET = "env-test-secret-0123456789-abcdefghij-klmnop"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@env.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; + +const { makeSelfHostApiHandler } = await import("../app"); +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +test("serves OAuth Authorization Server metadata at the origin root", async () => { + const res = await handler(new Request(`${BASE}/.well-known/oauth-authorization-server`)); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.issuer).toBeDefined(); + // mcp() advertises its DCR + authorize + token endpoints under /api/auth/mcp. + expect(String(body.authorization_endpoint)).toContain("/api/auth/mcp/authorize"); + expect(String(body.token_endpoint)).toContain("/api/auth/mcp/token"); + expect(String(body.registration_endpoint)).toContain("/api/auth/mcp/register"); +}); + +test("serves OAuth Protected Resource metadata at the origin root", async () => { + const res = await handler(new Request(`${BASE}/.well-known/oauth-protected-resource`)); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.resource).toBeDefined(); + expect(Array.isArray(body.authorization_servers)).toBe(true); +}); + +test("an unauthenticated /mcp request returns 401 with a WWW-Authenticate challenge", async () => { + const res = await handler( + new Request(`${BASE}/mcp`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }), + ); + expect(res.status).toBe(401); + const challenge = res.headers.get("www-authenticate") ?? ""; + expect(challenge).toContain("Bearer"); + expect(challenge).toContain("resource_metadata="); +}); + +// --- End-to-end MCP OAuth: DCR -> authorize -> token -> /mcp with bearer --- +const json = async (res: Response) => (await res.json()) as Record; + +const signUp = async (email: string): Promise => { + const inviteCode = await mintInviteCode(handler); + const res = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password: "password-12345678", name: email, inviteCode }), + }), + ); + expect(res.status).toBe(200); + // The session cookie lets /mcp/authorize skip the interactive login. + return res.headers.get("set-cookie") ?? ""; +}; + +const b64url = (buf: Uint8Array): string => + btoa(String.fromCharCode(...buf)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); + +test("MCP OAuth opaque-bearer flow authenticates /mcp end-to-end", async () => { + const cookie = await signUp("oauth@env.test"); + + // 1. Dynamic client registration (public/PKCE client). + const reg = await handler( + new Request(`${BASE}/api/auth/mcp/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "test-client", + redirect_uris: ["http://localhost:9999/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + }), + }), + ); + expect([200, 201]).toContain(reg.status); + const clientId = String((await json(reg)).client_id); + + // 2. PKCE authorize with the signed-in session cookie -> 302 to redirect_uri?code=… + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); + const challengeBytes = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)), + ); + const codeChallenge = b64url(challengeBytes); + const authorizeUrl = new URL(`${BASE}/api/auth/mcp/authorize`); + authorizeUrl.search = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: "http://localhost:9999/callback", + code_challenge: codeChallenge, + code_challenge_method: "S256", + scope: "openid", + }).toString(); + const authorize = await handler( + new Request(authorizeUrl, { headers: { cookie }, redirect: "manual" }), + ); + expect([302, 200]).toContain(authorize.status); + const location = authorize.headers.get("location") ?? ""; + const code = new URL(location).searchParams.get("code") ?? ""; + expect(code).not.toBe(""); + + // 3. Token exchange. + const token = await handler( + new Request(`${BASE}/api/auth/mcp/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: "http://localhost:9999/callback", + client_id: clientId, + code_verifier: verifier, + }).toString(), + }), + ); + expect(token.status).toBe(200); + const accessToken = String((await json(token)).access_token); + expect(accessToken).not.toBe(""); + + // 4. The opaque access token authenticates /mcp (initialize succeeds). + const init = await handler( + new Request(`${BASE}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "t", version: "1" }, + }, + }), + }), + ); + expect(init.status).toBe(200); + expect(init.headers.get("mcp-session-id")).not.toBe(null); +}); diff --git a/apps/host-selfhost/src/mcp/mcp.test.ts b/apps/host-selfhost/src/mcp/mcp.test.ts new file mode 100644 index 000000000..0f061adec --- /dev/null +++ b/apps/host-selfhost/src/mcp/mcp.test.ts @@ -0,0 +1,174 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "../testing/mint-invite"; + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-mcp-")); +process.env.BETTER_AUTH_SECRET = "mcp-test-secret-0123456789-abcdefghij-klmnop"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@mcp.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; + +const { makeSelfHostApiHandler } = await import("../app"); + +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +const signUp = async (email: string): Promise => { + const inviteCode = await mintInviteCode(handler); + const res = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password: "password-12345678", name: email, inviteCode }), + }), + ); + expect(res.status).toBe(200); + return res.headers.get("set-auth-token") ?? ""; +}; + +const mcp = (token: string, body: unknown, sessionId?: string) => + handler( + new Request(`${BASE}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }, + body: JSON.stringify(body), + }), + ); + +const initSession = async (token: string): Promise => { + const res = await mcp(token, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "t", version: "1" }, + }, + }); + expect(res.status).toBe(200); + const sessionId = res.headers.get("mcp-session-id") ?? ""; + expect(sessionId).not.toBe(""); + await res.text(); + await mcp(token, { jsonrpc: "2.0", method: "notifications/initialized" }, sessionId); + return sessionId; +}; + +test("an authenticated MCP client initializes, lists tools, and executes code", async () => { + const token = await signUp("alice@mcp.test"); + const sessionId = await initSession(token); + + const list = await mcp(token, { jsonrpc: "2.0", id: 2, method: "tools/list" }, sessionId); + const listBody = (await list.json()) as { result: { tools: ReadonlyArray<{ name: string }> } }; + expect(listBody.result.tools.map((tool) => tool.name)).toContain("execute"); + + const call = await mcp( + token, + { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "execute", arguments: { code: "export default 6 * 7" } }, + }, + sessionId, + ); + expect(call.status).toBe(200); + expect(JSON.stringify(await call.json())).toContain("42"); +}); + +test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { + const alice = await signUp("alice2@mcp.test"); + const bob = await signUp("bob2@mcp.test"); + const aliceSession = await initSession(alice); + + // Bob presents Alice's session id with his own token. Cross-bearer access is + // 403 JSON-RPC -32003 — unified with cloud's "does not belong" contract + // (deliberate self-host change from the prior 404). + const reuse = await mcp(bob, { jsonrpc: "2.0", id: 9, method: "tools/list" }, aliceSession); + expect(reuse.status).toBe(403); + const reuseBody = (await reuse.json()) as { + readonly jsonrpc: string; + readonly error?: { readonly code: number; readonly message: string }; + }; + expect(reuseBody.jsonrpc).toBe("2.0"); + expect(reuseBody.error?.code).toBe(-32003); + expect(reuseBody.error?.message).toMatch(/does not belong/i); + + // No credentials at all -> 401. + const noAuth = await handler( + new Request(`${BASE}/mcp`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "t", version: "1" }, + }, + }), + }), + ); + expect(noAuth.status).toBe(401); +}); + +test("an unknown MCP session id resolves to 404 (-32001), distinct from cross-bearer 403", async () => { + const carol = await signUp("carol@mcp.test"); + // A well-formed but never-created session id -> not-found, not forbidden. + const unknown = await mcp( + carol, + { jsonrpc: "2.0", id: 1, method: "tools/list" }, + crypto.randomUUID(), + ); + expect(unknown.status).toBe(404); + const body = (await unknown.json()) as { + readonly jsonrpc: string; + readonly error?: { readonly code: number; readonly message: string }; + }; + expect(body.jsonrpc).toBe("2.0"); + expect(body.error?.code).toBe(-32001); +}); + +test("GET /mcp without a session id is 400; DELETE without a session id is 204", async () => { + const dave = await signUp("dave@mcp.test"); + + // GET needs an existing session id (streamable-HTTP SSE channel) -> 400. + const get = await handler( + new Request(`${BASE}/mcp`, { + method: "GET", + headers: { authorization: `Bearer ${dave}`, accept: "text/event-stream" }, + }), + ); + expect(get.status).toBe(400); + const getBody = (await get.json()) as { + readonly jsonrpc: string; + readonly error?: { readonly code: number }; + }; + expect(getBody.jsonrpc).toBe("2.0"); + expect(getBody.error?.code).toBe(-32000); + + // DELETE with no session id is a no-op -> 204, empty body, no engine built. + const del = await handler( + new Request(`${BASE}/mcp`, { + method: "DELETE", + headers: { authorization: `Bearer ${dave}` }, + }), + ); + expect(del.status).toBe(204); + expect(await del.text()).toBe(""); +}); diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts new file mode 100644 index 000000000..51d80d9cb --- /dev/null +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -0,0 +1,40 @@ +import { Layer } from "effect"; + +import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; +import type { McpErrorReporter } from "@executor-js/host-mcp"; +import { + inMemoryMcpSessionsLayer, + makeInMemoryMcpSessionStore, + type InMemoryMcpSessionStore, +} from "@executor-js/host-mcp/in-memory-session-store"; + +import { ErrorCaptureLive } from "../observability"; +import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; +import { SelfHostExecutionStackLayer } from "../execution"; + +// --------------------------------------------------------------------------- +// Self-host McpSessionStore wiring. The store body (Maps, dispatch, ownership, +// lifetime), the per-session engine builder, and the console error reporter are +// ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer` +// / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host +// supplies only its fully-provided execution-stack layer (QuickJS over the +// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the +// identical seam with its own stack layer. +// --------------------------------------------------------------------------- + +export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-store"; + +/** Build the in-process session store (plus its `close()` hook) over the DB handle. */ +export const makeSelfHostMcpSessionStore = (db: SelfHostDbHandle): InMemoryMcpSessionStore => + makeInMemoryMcpSessionStore( + makeMcpBuildServer( + SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + ), + ); + +/** The `McpSessionStore` envelope seam over a freshly built in-process store. */ +export const selfHostMcpSessions = inMemoryMcpSessionsLayer; + +/** Route 500-defects through the host's console `ErrorCapture`. */ +export const selfHostMcpReporter: Layer.Layer = + makeConsoleMcpErrorReporter(ErrorCaptureLive); diff --git a/apps/host-selfhost/src/multi-user.test.ts b/apps/host-selfhost/src/multi-user.test.ts new file mode 100644 index 000000000..544b913e1 --- /dev/null +++ b/apps/host-selfhost/src/multi-user.test.ts @@ -0,0 +1,107 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "./testing/mint-invite"; + +// Real Better Auth path with multiple accounts. +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-multi-")); +process.env.BETTER_AUTH_SECRET = "multi-user-secret-0123456789-abcdefghij-klmn"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@multi.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; + +const { makeSelfHostApiHandler } = await import("./app"); + +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +const signUp = async (email: string): Promise => { + const inviteCode = await mintInviteCode(handler); + const res = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password: "password-12345678", name: email, inviteCode }), + }), + ); + expect(res.status).toBe(200); + const token = res.headers.get("set-auth-token") ?? ""; + expect(token).not.toBe(""); + return token; +}; + +const scopeOf = async (token: string): Promise<{ userScope: string; orgScope: string }> => { + const res = await handler( + new Request(`${BASE}/api/scope`, { headers: { authorization: `Bearer ${token}` } }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { stack: ReadonlyArray<{ id: string }> }; + return { userScope: body.stack[0]!.id, orgScope: body.stack[1]!.id }; +}; + +const setSecret = (token: string, scopeId: string, id: string, value: string) => + handler( + new Request(`${BASE}/api/scopes/${scopeId}/secrets`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ id, name: id, value }), + }), + ); + +const secretResolves = async (token: string, scopeId: string, id: string): Promise => { + const res = await handler( + new Request(`${BASE}/api/scopes/${scopeId}/secrets/${id}/status`, { + headers: { authorization: `Bearer ${token}` }, + }), + ); + if (res.status !== 200) return false; + const body = (await res.json()) as { status: string }; + return body.status === "resolved"; +}; + +const runCode = async (token: string, code: string) => { + const res = await handler( + new Request(`${BASE}/api/executions`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ code }), + }), + ); + return res; +}; + +test("multiple accounts share one org but isolate per-user secrets", async () => { + const alice = await signUp("alice@multi.test"); + const bob = await signUp("bob@multi.test"); + + const a = await scopeOf(alice); + const b = await scopeOf(bob); + + // Same single org, distinct personal (user-org) scopes. + expect(a.orgScope).toBe(b.orgScope); + expect(a.userScope).not.toBe(b.userScope); + + // Alice stores a personal secret on her user-org scope. + expect((await setSecret(alice, a.userScope, "gh", "alice-token")).status).toBe(200); + + // Alice can resolve her own personal secret; Bob cannot see it. + expect(await secretResolves(alice, a.userScope, "gh")).toBe(true); + expect(await secretResolves(bob, a.userScope, "gh")).toBe(false); + + // Org-scoped secrets ARE shared across members of the one org. + expect((await setSecret(alice, a.orgScope, "org-key", "shared-value")).status).toBe(200); + expect(await secretResolves(bob, a.orgScope, "org-key")).toBe(true); +}); + +test("each account can execute code in its own scoped sandbox", async () => { + const carol = await signUp("carol@multi.test"); + const res = await runCode(carol, "export default 21 * 2"); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string; text: string }; + expect(body.status).toBe("completed"); + expect(body.text).toBe("42"); +}); diff --git a/apps/host-selfhost/src/observability.ts b/apps/host-selfhost/src/observability.ts new file mode 100644 index 000000000..065cf8ddb --- /dev/null +++ b/apps/host-selfhost/src/observability.ts @@ -0,0 +1,11 @@ +// --------------------------------------------------------------------------- +// Self-host `ErrorCapture` — the shared console implementation with a +// `selfhost-` trace id prefix. Prints the squashed + pretty cause to stderr +// and returns a short correlation id that surfaces in the opaque 500 traceId, +// so operators can grep their logs. Cloud swaps in a Sentry-backed impl behind +// the same tag. +// --------------------------------------------------------------------------- + +import { consoleErrorCapture } from "@executor-js/api/server"; + +export const ErrorCaptureLive = consoleErrorCapture("selfhost"); diff --git a/apps/host-selfhost/src/plugins.ts b/apps/host-selfhost/src/plugins.ts new file mode 100644 index 000000000..bbd4c65ef --- /dev/null +++ b/apps/host-selfhost/src/plugins.ts @@ -0,0 +1,12 @@ +// Single shared instantiation of the self-host plugin list, mirroring +// `apps/cloud/src/api/cloud-plugins.ts`. The API composition +// (`composePluginApi`/`composePluginHandlerLayer`) and the per-request +// middleware (`providePluginExtensions`, `PluginExtensionServices<...>`) all +// derive their typed views from this one tuple, so adding/removing a plugin is +// a single `executor.config.ts` edit. The per-request executor builds its own +// fresh `executor.config.ts#plugins()` instances via the `PluginsProvider` seam +// (execution.ts). +import executorConfig from "../executor.config"; + +export const selfHostPlugins = executorConfig.plugins(); +export type SelfHostPlugins = typeof selfHostPlugins; diff --git a/apps/host-selfhost/src/scope-isolation.test.ts b/apps/host-selfhost/src/scope-isolation.test.ts new file mode 100644 index 000000000..317a6cfd4 --- /dev/null +++ b/apps/host-selfhost/src/scope-isolation.test.ts @@ -0,0 +1,55 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-iso-")); + +// Identity comes from request headers so a single handler can serve many +// distinct identities concurrently — the setup that would expose a +// cross-fiber scope leak if the executor's scope were shared rather than +// request-scoped. +const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app"); + +const { handler, dispose } = await makeSelfHostTestApp({ identity: headerIdentityLayer }); +afterAll(() => dispose()); + +const getScope = async (userId: string, organizationId: string) => { + const res = await handler( + new Request("http://localhost/api/scope", { + headers: { "x-test-user": userId, "x-test-org": organizationId }, + }), + ); + expect(res.status).toBe(200); + return (await res.json()) as { id: string; stack: ReadonlyArray<{ id: string }> }; +}; + +test("concurrent requests with distinct identities get disjoint, correct scope stacks", async () => { + // 6 identities × 8 interleaved requests each = 48 concurrent requests over + // the one long-lived SQLite handle. + const identities = Array.from({ length: 6 }, (_, i) => ({ + userId: `user-${i}`, + organizationId: `org-${i}`, + })); + const requests = Array.from({ length: 48 }, (_, i) => identities[i % identities.length]); + + const results = await Promise.all(requests.map((id) => getScope(id.userId, id.organizationId))); + + results.forEach((scope, i) => { + const { userId, organizationId } = requests[i]; + // Each response reflects ONLY its own request's identity — no bleed. + expect(scope.id).toBe(organizationId); + expect(scope.stack.map((s) => s.id)).toEqual([ + `user-org:${userId}:${organizationId}`, + organizationId, + ]); + }); +}); + +test("a request with no identity is rejected", async () => { + const res = await handler(new Request("http://localhost/api/scope")); + // singleAdmin never returns null, but the header provider does -> the + // middleware's unauthenticated path fires. + expect(res.status).toBeGreaterThanOrEqual(400); +}); diff --git a/apps/host-selfhost/src/secrets-integration.test.ts b/apps/host-selfhost/src/secrets-integration.test.ts new file mode 100644 index 000000000..3bf9cdb01 --- /dev/null +++ b/apps/host-selfhost/src/secrets-integration.test.ts @@ -0,0 +1,75 @@ +import { createClient } from "@libsql/client"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +const dataDir = mkdtempSync(join(tmpdir(), "eh-secrets-")); +process.env.EXECUTOR_DATA_DIR = dataDir; +process.env.EXECUTOR_SECRET_KEY = "integration-test-master-key"; + +const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app"); + +const { handler, dispose } = await makeSelfHostTestApp({ + identity: singleAdminIdentityLayer({ + userId: "admin", + organizationId: "default-org", + organizationName: "Default", + }), +}); +afterAll(() => dispose()); + +const NEEDLE = "PLAINTEXT_NEEDLE_9f3a"; + +test("a secret set via the API is stored encrypted at rest by the 'encrypted' provider", async () => { + const setRes = await handler( + new Request("http://localhost/api/scopes/default-org/secrets", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "gh-token", name: "GitHub", value: NEEDLE }), + }), + ); + expect(setRes.status).toBe(200); + const ref = (await setRes.json()) as { id: string; provider: string }; + expect(ref.id).toBe("gh-token"); + // The first writable provider is the encrypted one — it handled the write. + expect(ref.provider).toBe("encrypted"); + + // The status endpoint resolves it (decrypt round-trips through the provider). + const statusRes = await handler( + new Request("http://localhost/api/scopes/default-org/secrets/gh-token/status"), + ); + expect(statusRes.status).toBe(200); + expect(((await statusRes.json()) as { status: string }).status).toBe("resolved"); + + // Inspect the real SQLite file through a SEPARATE libSQL connection (the app's + // own libSQL client wrote it): the plaintext must NOT appear anywhere, and a + // versioned AES-GCM payload ("v1.") must be present. Reading this file through + // an independent connection also exercises the cross-connection visibility of + // FumaDB's writes. + const db = createClient({ url: `file:${join(dataDir, "data.db")}` }); + const tables = (await db.execute("SELECT name FROM sqlite_master WHERE type='table'")).rows.map( + // oxlint-disable-next-line executor/no-redundant-primitive-cast -- boundary: sqlite_master.name is TEXT; narrow libSQL's SQLValue to string for the table list + (r) => r.name as string, + ); + const cells: string[] = []; + for (const name of tables) { + const rows = (await db.execute(`SELECT * FROM "${name}"`)).rows; + for (const row of rows) { + for (const value of Object.values(row)) { + // Plugin-storage data is a BLOB (libSQL returns ArrayBuffer); decode it. + if (typeof value === "string") cells.push(value); + else if (value instanceof ArrayBuffer) cells.push(Buffer.from(value).toString("utf8")); + else if (ArrayBuffer.isView(value)) + cells.push( + Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("utf8"), + ); + } + } + } + db.close(); + + expect(cells.some((c) => c.includes(NEEDLE))).toBe(false); + expect(cells.some((c) => c.includes("v1."))).toBe(true); +}); diff --git a/apps/host-selfhost/src/serve.ts b/apps/host-selfhost/src/serve.ts new file mode 100644 index 000000000..2d8b1d271 --- /dev/null +++ b/apps/host-selfhost/src/serve.ts @@ -0,0 +1,57 @@ +/** + * Self-hosted Executor server. + * + * The entire HTTP app is ONE Effect `AppLayer`; the platform is just a provided + * layer. Self-host binds it to a listening Bun socket via `BunHttpServer.layer`. + * All routing lives in the Effect router — no hand-written fetch: + * - /api/* typed API (auth-gated) + * - /api/auth/* Better Auth + * - /mcp MCP (per-user) + * - /docs Swagger + * - everything else: the built web SPA (static files + index.html fallback) + * + * Run directly: bun run apps/host-selfhost/src/serve.ts (after `bun run build`) + */ + +import { fileURLToPath } from "node:url"; + +import { HttpRouter, HttpStaticServer } from "effect/unstable/http"; +import { BunFileSystem, BunHttpServer, BunPath, BunRuntime } from "@effect/platform-bun"; +import { Layer } from "effect"; + +import { makeSelfHostApp } from "./app"; +import { loadConfig } from "./config"; + +const distDir = fileURLToPath(new URL("../dist/", import.meta.url)); + +export const startServer = async (): Promise => { + const config = loadConfig(); + const { AppLayer } = await makeSelfHostApp(); + + // Serve the built SPA. Specific API/docs/auth/mcp routes take precedence; + // `spa: true` falls back to index.html for any other path (client routing). + const StaticLive = HttpStaticServer.layer({ root: distDir, spa: true }).pipe( + Layer.provide(BunFileSystem.layer), + Layer.provide(BunPath.layer), + ); + + const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, StaticLive)).pipe( + Layer.provide( + BunHttpServer.layer({ hostname: config.host, port: config.port, idleTimeout: 0 }), + ), + ); + + await BunRuntime.runMain(Layer.launch(ServerLive)); +}; + +if (import.meta.main) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process entry point; turn a pre-runtime startup failure (config/DB open) into a diagnosable log + non-zero exit instead of an opaque unhandled rejection + try { + await startServer(); + } catch (error) { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: format an arbitrary thrown startup error for the container log + const detail = error instanceof Error ? (error.stack ?? error.message) : error; + console.error("[executor] failed to start:", detail); + process.exit(1); + } +} diff --git a/apps/host-selfhost/src/sources-mcp.test.ts b/apps/host-selfhost/src/sources-mcp.test.ts new file mode 100644 index 000000000..6954a8740 --- /dev/null +++ b/apps/host-selfhost/src/sources-mcp.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect, Layer } from "effect"; +import { afterAll, expect, test } from "@effect/vitest"; + +import { makeScopedExecutor } from "@executor-js/api/server"; + +import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; +import { mintInviteCode } from "./testing/mint-invite"; +import { SelfHostScopedExecutorSeams } from "./execution"; +import type { SelfHostPlugins } from "./plugins"; + +// The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb, +// fresh per-request plugins, host config) over the shared `makeScopedExecutor`, +// leaving `SelfHostDb` as the only requirement (the production path provides the +// same seams via `SelfHostExecutionStackLayer`). +const createScopedExecutor = ( + accountId: string, + organizationId: string, + organizationName: string, +) => + makeScopedExecutor(accountId, organizationId, organizationName).pipe( + Effect.provide(SelfHostScopedExecutorSeams), + ); + +// End-to-end: an org source is reachable from a user's MCP `execute` sandbox. +const dataDir = mkdtempSync(join(tmpdir(), "eh-srcmcp-")); +const dbPath = join(dataDir, "data.db"); +process.env.EXECUTOR_DATA_DIR = dataDir; +process.env.BETTER_AUTH_SECRET = "srcmcp-secret-0123456789-abcdefghij-klmnop"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@srcmcp.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; + +const TINY_SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Tiny", version: "1.0.0" }, + servers: [{ url: "https://httpbin.org" }], + paths: { + "/get": { + get: { + operationId: "httpGet", + summary: "Tiny get operation", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +const { makeSelfHostApiHandler } = await import("./app"); +const { handler, dispose } = await makeSelfHostApiHandler({ dbPath }); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +const addOrgSource = async (organizationId: string): Promise => { + // Install the source at the (Better Auth) org scope, on its own connection to + // the shared DB file. WAL makes the committed rows visible to the server. + const seedDb = await createSelfHostDb({ + path: dbPath, + namespace: "executor_selfhost", + version: "1.0.0", + }); + await Effect.runPromise( + Effect.gen(function* () { + const admin = yield* createScopedExecutor("seed", organizationId, "Default"); + yield* admin.openapi.addSpec({ + spec: { kind: "blob", value: TINY_SPEC }, + scope: organizationId, + name: "tiny", + namespace: "tiny", + baseUrl: "", + }); + }).pipe(Effect.provide(Layer.succeed(SelfHostDb)(seedDb)), Effect.scoped), + ); + await seedDb.close(); +}; + +test("a user's MCP execute sandbox can reach an org source's tools", async () => { + const inviteCode = await mintInviteCode(handler); + const su = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "u@srcmcp.test", + password: "password-12345678", + name: "U", + inviteCode, + }), + }), + ); + const token = su.headers.get("set-auth-token") ?? ""; + expect(token).not.toBe(""); + + // The user's real org scope (Better Auth assigns a random org id). + const scopeRes = await handler( + new Request(`${BASE}/api/scope`, { headers: { authorization: `Bearer ${token}` } }), + ); + const organizationId = ((await scopeRes.json()) as { stack: ReadonlyArray<{ id: string }> }) + .stack[1]!.id; + + await addOrgSource(organizationId); + + const mcp = (body: unknown, sessionId?: string) => + handler( + new Request(`${BASE}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }, + body: JSON.stringify(body), + }), + ); + + const init = await mcp({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "t", version: "1" }, + }, + }); + const sessionId = init.headers.get("mcp-session-id") ?? ""; + expect(sessionId).not.toBe(""); + await init.text(); + await mcp({ jsonrpc: "2.0", method: "notifications/initialized" }, sessionId); + + const call = await mcp( + { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "execute", + arguments: { + code: 'export default (await tools.search({ query: "tiny get operation", limit: 10 })).items.map((m) => m.path)', + }, + }, + }, + sessionId, + ); + expect(call.status).toBe(200); + expect(JSON.stringify(await call.json())).toContain("tiny"); +}); diff --git a/apps/host-selfhost/src/sources.test.ts b/apps/host-selfhost/src/sources.test.ts new file mode 100644 index 000000000..6eebe845b --- /dev/null +++ b/apps/host-selfhost/src/sources.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect, Layer } from "effect"; +import { afterAll, expect, test } from "@effect/vitest"; + +import { makeScopedExecutor } from "@executor-js/api/server"; + +import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; +import { SelfHostScopedExecutorSeams } from "./execution"; +import type { SelfHostPlugins } from "./plugins"; + +// The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb, +// fresh per-request plugins, host config) over the shared `makeScopedExecutor`, +// leaving `SelfHostDb` as the only requirement (the production path provides the +// same seams via `SelfHostExecutionStackLayer`). +const createScopedExecutor = ( + accountId: string, + organizationId: string, + organizationName: string, +) => + makeScopedExecutor(accountId, organizationId, organizationName).pipe( + Effect.provide(SelfHostScopedExecutorSeams), + ); + +const dataDir = mkdtempSync(join(tmpdir(), "eh-src-")); +process.env.EXECUTOR_DATA_DIR = dataDir; + +const dbHandle = await createSelfHostDb({ + path: join(dataDir, "data.db"), + namespace: "executor_selfhost", + version: "1.0.0", +}); +const dbLayer = Layer.succeed(SelfHostDb)(dbHandle); +afterAll(() => dbHandle.close()); + +// Inline OpenAPI spec so the test doesn't depend on the network to register. +const TINY_SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Tiny", version: "1.0.0" }, + servers: [{ url: "https://httpbin.org" }], + paths: { + "/get": { + get: { operationId: "httpGet", summary: "GET", responses: { "200": { description: "ok" } } }, + }, + }, +}); + +test("an org-scoped OpenAPI source registers tools shared across org members", async () => { + // Alice (a member) adds a source at the org install scope. + const added = await Effect.runPromise( + Effect.gen(function* () { + const alice = yield* createScopedExecutor("alice", "default-org", "Default"); + return yield* alice.openapi.addSpec({ + spec: { kind: "blob", value: TINY_SPEC }, + scope: "default-org", + name: "tiny", + namespace: "tiny", + baseUrl: "", + }); + }).pipe(Effect.provide(dbLayer), Effect.scoped), + ); + expect(added.sourceId).toBe("tiny"); + expect(added.toolCount).toBeGreaterThan(0); + + // Bob — a different user in the SAME org — sees the org-scoped source's tools. + const bobToolIds = await Effect.runPromise( + Effect.gen(function* () { + const bob = yield* createScopedExecutor("bob", "default-org", "Default"); + const tools = yield* bob.tools.list(); + return tools.map((tool) => String(tool.id)); + }).pipe(Effect.provide(dbLayer), Effect.scoped), + ); + expect(bobToolIds.some((id) => id.startsWith("tiny."))).toBe(true); +}); diff --git a/apps/host-selfhost/src/system/api.ts b/apps/host-selfhost/src/system/api.ts new file mode 100644 index 000000000..ccc9d3568 --- /dev/null +++ b/apps/host-selfhost/src/system/api.ts @@ -0,0 +1,38 @@ +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; + +// --------------------------------------------------------------------------- +// Public system API — unauthenticated status endpoints served under /api. +// +// GET /api/health readiness probe (used by the container healthcheck) +// GET /api/setup-status whether the instance still needs first-run setup, so +// the pre-login SPA can route a fresh operator to /setup +// +// Both are deliberately unauthenticated and return only booleans/status — no +// sensitive data — so they can be read before anyone has signed in. +// --------------------------------------------------------------------------- + +export class SystemError extends Schema.TaggedErrorClass()( + "SystemError", + { message: Schema.String }, + { httpApiStatus: 500 }, +) {} + +export const HealthResponse = Schema.Struct({ status: Schema.String }); +export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean }); + +export const SystemApi = HttpApiGroup.make("system") + .add( + HttpApiEndpoint.get("health", "/health", { + success: HealthResponse, + error: [SystemError], + }), + ) + .add( + HttpApiEndpoint.get("setupStatus", "/setup-status", { + success: SetupStatusResponse, + error: [SystemError], + }), + ); + +export const SystemHttpApi = HttpApi.make("executor-self-host-system").add(SystemApi); diff --git a/apps/host-selfhost/src/system/handlers.ts b/apps/host-selfhost/src/system/handlers.ts new file mode 100644 index 000000000..6a0f9a381 --- /dev/null +++ b/apps/host-selfhost/src/system/handlers.ts @@ -0,0 +1,66 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; + +import { SystemError, SystemHttpApi } from "./api"; +import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth"; +import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; + +// --------------------------------------------------------------------------- +// Handlers for the public system API. Unauthenticated; every DB touch is an +// Effect.tryPromise. `health` fails soft (a DB hiccup reports "degraded", it +// never throws); `setup-status` reports whether the one org has zero members. +// --------------------------------------------------------------------------- + +export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (handlers) => + handlers + .handle("health", () => + Effect.gen(function* () { + const { client } = yield* SelfHostDb; + const status = yield* Effect.tryPromise({ + try: () => client.execute("SELECT 1"), + catch: () => new SystemError({ message: "database unreachable" }), + }).pipe( + Effect.as("ok"), + Effect.orElseSucceed(() => "degraded"), + ); + return { status }; + }), + ) + .handle("setupStatus", () => + Effect.gen(function* () { + const { auth, organizationId } = yield* BetterAuth; + // Count via Better Auth's adapter (see countOrgMembers) so this read is + // consistent with how memberships are written. + const count = yield* Effect.tryPromise({ + try: () => countOrgMembers(auth, organizationId), + catch: () => new SystemError({ message: "failed to read setup status" }), + }); + return { needsSetup: count === 0 }; + }), + ), +); + +export interface SelfHostSystemApiDeps { + readonly betterAuth: BetterAuthHandle; + readonly db: SelfHostDbHandle; + readonly mountPrefix: `/${string}`; +} + +/** Mountable extension route layer (see makeSelfHostAdminApiLayer). */ +export const makeSelfHostSystemApiLayer = ({ + betterAuth, + db, + mountPrefix, +}: SelfHostSystemApiDeps) => { + const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), + ); + return HttpApiBuilder.layer(SystemHttpApi).pipe( + Layer.provide(SystemHandlers), + Layer.provide(prefixedRouter), + HttpRouter.provideRequest( + Layer.mergeAll(Layer.succeed(BetterAuth)(betterAuth), Layer.succeed(SelfHostDb)(db)), + ), + ); +}; diff --git a/apps/host-selfhost/src/testing/mint-invite.ts b/apps/host-selfhost/src/testing/mint-invite.ts new file mode 100644 index 000000000..2dc90c73e --- /dev/null +++ b/apps/host-selfhost/src/testing/mint-invite.ts @@ -0,0 +1,56 @@ +import { Effect, Layer } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient } from "effect/unstable/http"; + +import { AdminHttpApi } from "../admin/api"; +import { type InviteRole } from "../auth/invites"; + +// Test helper: mint an invite code through the TYPED admin HttpApi client, the +// same surface the web app calls — no raw request building, no direct DB poke. +// The one unavoidable raw call is the bootstrap admin's Better Auth sign-in (an +// auth boundary, not an HttpApi surface); everything after is the typed client. + +type Handler = (request: Request) => Promise; + +const BASE = "http://localhost:4788/api"; + +const signInToken = async (handler: Handler, email: string, password: string): Promise => { + const response = await handler( + new Request("http://localhost:4788/api/auth/sign-in/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password }), + }), + ); + return response.headers.get("set-auth-token") ?? ""; +}; + +// A FetchHttpClient backed by the in-process handler, carrying the admin bearer. +const clientLayer = (handler: Handler, token: string) => + FetchHttpClient.layer.pipe( + Layer.provide( + Layer.succeed(FetchHttpClient.Fetch)(((input: RequestInfo | URL, init?: RequestInit) => { + const base = input instanceof Request ? input : new Request(input, init); + const request = new Request(base, { + headers: { ...Object.fromEntries(base.headers), authorization: `Bearer ${token}` }, + }); + return handler(request); + }) as typeof globalThis.fetch), + ), + ); + +export const mintInviteCode = async ( + handler: Handler, + role: InviteRole = "member", +): Promise => { + const token = await signInToken( + handler, + process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL!, + process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD!, + ); + return Effect.gen(function* () { + const client = yield* HttpApiClient.make(AdminHttpApi, { baseUrl: BASE }); + const invite = yield* client.admin.createInvite({ payload: { role } }); + return invite.code; + }).pipe(Effect.provide(clientLayer(handler, token)), Effect.runPromise); +}; diff --git a/apps/host-selfhost/src/testing/test-app.ts b/apps/host-selfhost/src/testing/test-app.ts new file mode 100644 index 000000000..9dc707d90 --- /dev/null +++ b/apps/host-selfhost/src/testing/test-app.ts @@ -0,0 +1,224 @@ +import { HttpApiSwagger } from "effect/unstable/httpapi"; +import { Effect, Layer } from "effect"; + +import { + composePluginApi, + ExecutorApp, + IdentityProvider, + type Principal, + textFailureStrategy, + Unauthorized, +} from "@executor-js/api/server"; +import { + authenticated, + McpAuthProvider, + unauthorized, + type AuthOutcome, +} from "@executor-js/host-mcp"; + +import { createSelfHostDb, SelfHostDb, SelfHostDbProvider } from "../db/self-host-db"; +import { + SelfHostCodeExecutorProvider, + SelfHostHostConfig, + SelfHostPluginsProvider, +} from "../execution"; +import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; +import { + makeSelfHostMcpSessionStore, + selfHostMcpReporter, + selfHostMcpSessions, +} from "../mcp/session-store"; +import { selfHostPlugins } from "../plugins"; +import { ErrorCaptureLive } from "../observability"; + +// =========================================================================== +// Self-host TEST harness — the throwaway composition tests use to exercise the +// shared app graph WITHOUT booting Better Auth. +// +// Production (`makeSelfHostApp`) is unconditional: it always builds Better Auth +// over the libSQL file, mounts the account API, and serves the real MCP OAuth +// seam. Tests that don't need a real auth backend (scope-stack isolation, the +// QuickJS sandbox, encrypted-secret-at-rest) want a trivial, deterministic +// identity and no auth secret. That test-only wiring used to live in production +// behind `if (injectedIdentity)` branches; it now lives HERE. +// +// `makeSelfHostTestApp` composes `ExecutorApp.make` directly with: +// - a test `IdentityProvider` (single-admin or header-driven), +// - a stub `McpAuthProvider` (no OAuth Authorization Server; authenticate via +// the same injected identity), +// - NO account API (Better Auth is never constructed), +// - a throwaway libSQL path. +// +// Tests that DO need the real Better Auth backend (multi-user sign-up, the MCP +// OAuth DCR -> authorize -> token flow) use the production `makeSelfHostApiHandler` +// instead — that path is the honest unconditional composition. +// =========================================================================== + +// --------------------------------------------------------------------------- +// Test identities — trivial `IdentityProvider` implementations of the shared +// tag. The single-admin one resolves every request to one configured admin; the +// header-driven one reads the identity from request headers so a single handler +// can serve many distinct identities concurrently (cross-fiber scope-leak test). +// --------------------------------------------------------------------------- + +export interface SingleAdminOptions { + readonly userId: string; + readonly organizationId: string; + readonly organizationName: string; + readonly email?: string; +} + +/** Every request is the configured single admin. */ +export const singleAdminIdentityLayer = ( + options: SingleAdminOptions, +): Layer.Layer => + Layer.succeed( + IdentityProvider, + IdentityProvider.of({ + authenticate: () => + Effect.succeed({ + accountId: options.userId, + organizationId: options.organizationId, + organizationName: options.organizationName, + email: options.email ?? "admin@localhost", + name: "Admin", + avatarUrl: null, + roles: ["admin"], + }), + }), + ); + +/** + * Resolve the identity from `x-test-user` / `x-test-org` headers (missing either + * -> `Unauthorized`). Lets one handler serve many identities concurrently. + */ +export const headerIdentityLayer: Layer.Layer = Layer.succeed( + IdentityProvider, + IdentityProvider.of({ + authenticate: (request) => { + const userId = request.headers.get("x-test-user"); + const organizationId = request.headers.get("x-test-org"); + if (!userId || !organizationId) return Effect.fail(new Unauthorized()); + return Effect.succeed({ + accountId: userId, + organizationId, + organizationName: `Org ${organizationId}`, + email: `${userId}@test`, + name: userId, + avatarUrl: null, + roles: ["admin"], + }); + }, + }), +); + +// --------------------------------------------------------------------------- +// Stub McpAuthProvider — no OAuth Authorization Server, so the declared metadata +// docs 404; authentication delegates to the injected test `IdentityProvider`. +// Keeps /mcp mountable under the test composition without Better Auth. +// --------------------------------------------------------------------------- + +const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; +const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; + +const resourceMetadataUrlFor = (request: Request): string => + `${new URL(request.url).origin}${PROTECTED_RESOURCE_METADATA_PATH}`; + +const notFoundResponse = (): Effect.Effect => + Effect.sync(() => new Response("Not Found", { status: 404 })); + +const stubMcpAuth: Layer.Layer = Layer.effect( + McpAuthProvider, + Effect.gen(function* () { + const fallback = yield* IdentityProvider; + const challengeFor = (request: Request): string => + `Bearer resource_metadata="${resourceMetadataUrlFor(request)}"`; + return { + discoveryRoutes: [ + { path: PROTECTED_RESOURCE_METADATA_PATH, handler: notFoundResponse }, + { path: AUTHORIZATION_SERVER_METADATA_PATH, handler: notFoundResponse }, + ], + resourceMetadataUrl: resourceMetadataUrlFor, + authenticate: (request: Request): Effect.Effect => + fallback.authenticate(request).pipe( + Effect.map((principal) => + principal ? authenticated(principal) : unauthorized(challengeFor(request)), + ), + Effect.catchTags({ + Unauthorized: () => Effect.succeed(unauthorized(challengeFor(request))), + NoOrganization: () => Effect.succeed(unauthorized(challengeFor(request))), + }), + ), + }; + }), +); + +// --------------------------------------------------------------------------- +// makeSelfHostTestApp — the same `ExecutorApp.make` composition the production +// app uses, but with the test identity + stub MCP auth + no account, over a +// throwaway libSQL file. Returns the same `{ handler, dispose }` shape the +// production `makeSelfHostApiHandler` returns. +// --------------------------------------------------------------------------- + +export interface MakeSelfHostTestAppOptions { + /** The test `IdentityProvider` (single-admin / header-driven). */ + readonly identity: Layer.Layer; + /** Override the SQLite path (defaults to the config data dir). */ + readonly dbPath?: string; +} + +export interface SelfHostTestHandler { + /** Unified web handler: serves /api/*, /mcp, and /docs (no /api/auth). */ + readonly handler: (request: Request) => Promise; + readonly dispose: () => Promise; +} + +export const makeSelfHostTestApp = async ( + options: MakeSelfHostTestAppOptions, +): Promise => { + const config = loadConfig(); + + const dbHandle = await createSelfHostDb({ + path: options.dbPath ?? config.dbPath, + namespace: SELF_HOST_NAMESPACE, + version: SELF_HOST_SCHEMA_VERSION, + }); + + const sessionStore = makeSelfHostMcpSessionStore(dbHandle); + + const { toWebHandler } = ExecutorApp.make({ + plugins: selfHostPlugins, + providers: { + identity: options.identity, + db: SelfHostDbProvider, + engine: { codeExecutor: SelfHostCodeExecutorProvider }, + mcp: { + auth: stubMcpAuth, + sessions: selfHostMcpSessions(sessionStore), + reporter: selfHostMcpReporter, + }, + plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, + errorCapture: ErrorCaptureLive, + }, + extensions: { + routes: [ + HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), + ], + }, + config: { mountPrefix: "/api", failure: textFailureStrategy }, + // The test identity is boot-scoped exactly as production's is: no + // requestScoped layer, so the execution middleware leaves IdentityProvider + // residual and `provideMerge(boot)` supplies it. + boot: Layer.merge(Layer.succeed(SelfHostDb)(dbHandle), options.identity), + }); + + const web = toWebHandler(); + return { + handler: web.handler, + dispose: async () => { + await web.dispose(); + await sessionStore.close(); + await dbHandle.close(); + }, + }; +}; diff --git a/apps/host-selfhost/tsconfig.json b/apps/host-selfhost/tsconfig.json new file mode 100644 index 000000000..e214693ed --- /dev/null +++ b/apps/host-selfhost/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["bun-types"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": { + "preferSchemaOverJson": "off" + } + } + ] + }, + "include": ["src/**/*.ts", "executor.config.ts"] +} diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts new file mode 100644 index 000000000..2b1aab913 --- /dev/null +++ b/apps/host-selfhost/vite.config.ts @@ -0,0 +1,136 @@ +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +import { defineConfig, type Plugin } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import executorVitePlugin from "@executor-js/vite-plugin"; + +// Self-host web SPA. Mirrors @executor-js/app's vite plugin bundle, but points +// the TanStack router codegen at THIS app's routes (web/routes) so we get the +// multiplayer shell + Better-Auth gate (routes/__root.tsx) instead of the +// personal-mode local shell. executorVitePlugin feeds plugin client bundles +// from our executor.config.ts into `virtual:executor/plugins-client`. +const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); +const DEV_PORT = 5173; + +// Dev defaults so `bun run dev` boots the full stack with zero manual env. +// Set at module load (before any plugin/executor.config reads them). Override +// via real env for anything you care about (esp. BETTER_AUTH_SECRET in prod). +process.env.EXECUTOR_DATA_DIR ??= fileURLToPath(new URL("./.executor-dev/", import.meta.url)); +process.env.BETTER_AUTH_SECRET ??= "executor-selfhost-dev-secret-change-me-0123456789"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL ??= "admin@example.com"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD ??= "executor-dev-admin"; +process.env.EXECUTOR_WEB_BASE_URL ??= `http://localhost:${DEV_PORT}`; + +// Dev-only: forward /api, /mcp, /docs to the self-host Effect handler in-process +// (the same web handler serve.ts binds). Requires vite to run under Bun +// (`bunx --bun vite dev`) because the handler opens a bun:sqlite DB. No path +// stripping — the self-host API is served under /api by the prefixed router, so +// the handler expects the full path. Handler rebuilds when src/ changes. +function executorApiPlugin(): Plugin { + let handlerPromise: Promise<{ handler: (request: Request) => Promise }> | null = null; + const getHandler = async () => { + if (!handlerPromise) { + // Computed specifier so Vite's Node-based config loader does NOT statically + // follow this into ./src/api/api (which imports @executor-js/host-mcp, whose + // extensionless re-exports resolve under Bun but not Node ESM). It only runs + // at dev-server request time, under `bunx --bun vite dev`. + const apiModule = new URL("./src/api/api.ts", import.meta.url).href; + handlerPromise = import(apiModule).then((m) => m.makeSelfHostApiHandler()); + } + return handlerPromise; + }; + + return { + name: "executor-selfhost-api", + apply: "serve", + configureServer(server) { + server.watcher.on("change", (path) => { + if (path.includes("/src/") || path.endsWith("/executor.config.ts")) handlerPromise = null; + }); + server.middlewares.use(async (req, res, next) => { + const rawUrl = req.url ?? "/"; + const handled = + rawUrl === "/api" || + rawUrl.startsWith("/api/") || + rawUrl.startsWith("/mcp") || + rawUrl.startsWith("/docs"); + if (!handled) return next(); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite dev middleware must convert handler failures into HTTP 500 responses + try { + const { handler } = await getHandler(); + const origin = `http://${req.headers.host ?? `localhost:${DEV_PORT}`}`; + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value); + } + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + const webRequest = new Request(new URL(rawUrl, origin), { + method: req.method, + headers, + body: hasBody ? Readable.toWeb(req) : undefined, + duplex: hasBody ? "half" : undefined, + } as RequestInit); + + const response = await handler(webRequest); + res.statusCode = response.status; + response.headers.forEach((value, key) => res.setHeader(key, value)); + if (response.body) { + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + } + res.end(); + } catch (err) { + console.error("[executor-selfhost-api]", err); + if (!res.headersSent) { + res.statusCode = 500; + res.end("Internal Server Error"); + } + } + }); + }, + }; +} + +export default defineConfig({ + root: fileURLToPath(new URL("./web/", import.meta.url)), + publicDir: fileURLToPath(new URL("../../packages/app/public/", import.meta.url)), + build: { + outDir: fileURLToPath(new URL("./dist/", import.meta.url)), + emptyOutDir: true, + }, + resolve: { + alias: { "@executor-app": APP_ROOT }, + dedupe: ["react", "react-dom"], + }, + define: { + "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-selfhost"), + "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), + }, + server: { + port: DEV_PORT, + fs: { allow: [fileURLToPath(new URL("../../", import.meta.url))] }, + }, + plugins: [ + executorApiPlugin(), + tailwindcss(), + executorVitePlugin({ + configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), + }), + tanstackRouter({ + target: "react", + autoCodeSplitting: true, + routesDirectory: fileURLToPath(new URL("./web/routes", import.meta.url)), + generatedRouteTree: fileURLToPath(new URL("./web/routeTree.gen.ts", import.meta.url)), + }), + ...react(), + ], +}); diff --git a/apps/host-selfhost/vitest.config.ts b/apps/host-selfhost/vitest.config.ts new file mode 100644 index 000000000..5bfa2d586 --- /dev/null +++ b/apps/host-selfhost/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + passWithNoTests: true, + }, +}); diff --git a/apps/host-selfhost/web/admin-atoms.tsx b/apps/host-selfhost/web/admin-atoms.tsx new file mode 100644 index 000000000..f97a7453c --- /dev/null +++ b/apps/host-selfhost/web/admin-atoms.tsx @@ -0,0 +1,22 @@ +import { AdminApiClient } from "./admin-client"; + +// --------------------------------------------------------------------------- +// Self-host admin atoms — typed, cached, reactive queries/mutations over the +// app-local /api/admin/* invite-code surface, on the same atom registry as the +// shared account atoms. Member management reuses the shared account atoms; only +// invite codes are new here. +// --------------------------------------------------------------------------- + +// Local reactivity key: invites only matter within this client, so they don't +// belong in the shared cross-client ReactivityKey set. +const INVITES_KEY = "self-host:invites"; + +export const invitesAtom = AdminApiClient.query("admin", "listInvites", { + reactivityKeys: [INVITES_KEY], +}); + +export const createInvite = AdminApiClient.mutation("admin", "createInvite"); +export const revokeInvite = AdminApiClient.mutation("admin", "revokeInvite"); + +/** Mutations that change the invite list pass these at the call site. */ +export const inviteWriteKeys = [INVITES_KEY] as const; diff --git a/apps/host-selfhost/web/admin-client.tsx b/apps/host-selfhost/web/admin-client.tsx new file mode 100644 index 000000000..82141d8e1 --- /dev/null +++ b/apps/host-selfhost/web/admin-client.tsx @@ -0,0 +1,35 @@ +import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import * as Effect from "effect/Effect"; + +import { reportApiClientInfrastructureCause } from "@executor-js/react/api/client"; +import { + getExecutorApiBaseUrl, + getExecutorServerAuthorizationHeader, +} from "@executor-js/react/api/server-connection"; + +import { AdminHttpApi } from "../src/admin/api"; + +// --------------------------------------------------------------------------- +// Self-host admin atom client — the invite-code surface (/api/admin/*). +// +// Same construction as the shared AccountApiClient (base-url prepend + +// same-origin session cookie / optional bearer), but for the app-local admin +// HttpApi. Self-host only: cloud has no invite codes. +// --------------------------------------------------------------------------- + +const AdminApiClient = AtomHttpApi.Service<"SelfHostAdminApiClient">()("SelfHostAdminApiClient", { + api: AdminHttpApi, + httpClient: FetchHttpClient.layer, + transformClient: HttpClient.mapRequest((request) => { + let next = HttpClientRequest.prependUrl(request, getExecutorApiBaseUrl()); + const authorization = getExecutorServerAuthorizationHeader(); + if (authorization) { + next = HttpClientRequest.setHeader(next, "authorization", authorization); + } + return next; + }), + transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), +}); + +export { AdminApiClient }; diff --git a/apps/host-selfhost/web/auth-client.ts b/apps/host-selfhost/web/auth-client.ts new file mode 100644 index 000000000..1a9da926a --- /dev/null +++ b/apps/host-selfhost/web/auth-client.ts @@ -0,0 +1,9 @@ +import { createAuthClient } from "better-auth/react"; + +// Better Auth browser client. Talks to the self-host server's /api/auth (same +// origin); the session cookie it sets is what the shared AuthProvider's +// /account/me query and all API calls authenticate with. Only the login form +// and sign-out use this — auth STATE comes from the shared AuthProvider. +export const authClient = createAuthClient({ + baseURL: `${window.location.origin}/api/auth`, +}); diff --git a/apps/host-selfhost/web/entry-client.tsx b/apps/host-selfhost/web/entry-client.tsx new file mode 100644 index 000000000..816855e72 --- /dev/null +++ b/apps/host-selfhost/web/entry-client.tsx @@ -0,0 +1,15 @@ +import ReactDOM from "react-dom/client"; +import { RouterProvider } from "@tanstack/react-router"; + +import "@executor-js/react/globals.css"; + +import { getRouter } from "./router"; + +// The whole app — shell, pages, and the Better-Auth-gated multiplayer surface — +// is the shared @executor-js/react composition wired in routes/__root.tsx. +const router = getRouter(); +const rootElement = document.getElementById("root"); + +if (rootElement) { + ReactDOM.createRoot(rootElement).render(); +} diff --git a/apps/host-selfhost/web/index.html b/apps/host-selfhost/web/index.html new file mode 100644 index 000000000..5e34d435f --- /dev/null +++ b/apps/host-selfhost/web/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + Executor (self-hosted) + + + + + +
+ + + diff --git a/apps/host-selfhost/web/login.tsx b/apps/host-selfhost/web/login.tsx new file mode 100644 index 000000000..ca8a9e1eb --- /dev/null +++ b/apps/host-selfhost/web/login.tsx @@ -0,0 +1,122 @@ +import { useState, type FormEvent } from "react"; + +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; + +import { authClient } from "./auth-client"; + +// Self-host login: email + password sign-in via Better Auth. On success we +// reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps +// in the app. (Cloud's equivalent is a WorkOS redirect — this is the +// provider-specific piece injected into the shared shell.) +// +// There is no self-signup here: open registration is closed. New people join by +// redeeming an invite — either the full /join/ link, or by entering the +// code here ("Have an invite code?"), which forwards to the same join page. +export const LoginPage = () => { + const [mode, setMode] = useState<"signin" | "code">("signin"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const signIn = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + const result = await authClient.signIn.email({ email, password }); + if (result.error) { + setBusy(false); + setError(result.error.message ?? "Sign in failed"); + return; + } + window.location.href = "/"; + }; + + const redeem = (event: FormEvent) => { + event.preventDefault(); + const trimmed = code.trim(); + if (!trimmed) return; + // Forward to the join page, which collects name/email/password and redeems. + window.location.href = `/join/${encodeURIComponent(trimmed)}`; + }; + + return ( +
+
+
+

Executor

+

+ {mode === "signin" ? "Sign in to your instance" : "Join with your invite code"} +

+
+ + {mode === "signin" ? ( +
+
+ + setEmail((e.target as HTMLInputElement).value)} + autoComplete="email" + required + /> +
+
+ + setPassword((e.target as HTMLInputElement).value)} + autoComplete="current-password" + required + minLength={8} + /> +
+ {error &&

{error}

} + +
+ ) : ( +
+
+ + setCode((e.target as HTMLInputElement).value)} + autoFocus + /> +
+ +
+ )} + +
+ +
+
+
+ ); +}; diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts new file mode 100644 index 000000000..417e8afd4 --- /dev/null +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -0,0 +1,294 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ToolsRouteImport } from './routes/tools' +import { Route as SecretsRouteImport } from './routes/secrets' +import { Route as PoliciesRouteImport } from './routes/policies' +import { Route as ConnectionsRouteImport } from './routes/connections' +import { Route as ApiKeysRouteImport } from './routes/api-keys' +import { Route as AdminRouteImport } from './routes/admin' +import { Route as IndexRouteImport } from './routes/index' +import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' +import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId' +import { Route as JoinCodeRouteImport } from './routes/join.$code' +import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' +import { Route as PluginsPluginIdSplatRouteImport } from './routes/plugins.$pluginId.$' + +const ToolsRoute = ToolsRouteImport.update({ + id: '/tools', + path: '/tools', + getParentRoute: () => rootRouteImport, +} as any) +const SecretsRoute = SecretsRouteImport.update({ + id: '/secrets', + path: '/secrets', + getParentRoute: () => rootRouteImport, +} as any) +const PoliciesRoute = PoliciesRouteImport.update({ + id: '/policies', + path: '/policies', + getParentRoute: () => rootRouteImport, +} as any) +const ConnectionsRoute = ConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', + getParentRoute: () => rootRouteImport, +} as any) +const ApiKeysRoute = ApiKeysRouteImport.update({ + id: '/api-keys', + path: '/api-keys', + getParentRoute: () => rootRouteImport, +} as any) +const AdminRoute = AdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ + id: '/sources/$namespace', + path: '/sources/$namespace', + getParentRoute: () => rootRouteImport, +} as any) +const ResumeExecutionIdRoute = ResumeExecutionIdRouteImport.update({ + id: '/resume/$executionId', + path: '/resume/$executionId', + getParentRoute: () => rootRouteImport, +} as any) +const JoinCodeRoute = JoinCodeRouteImport.update({ + id: '/join/$code', + path: '/join/$code', + getParentRoute: () => rootRouteImport, +} as any) +const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ + id: '/sources/add/$pluginKey', + path: '/sources/add/$pluginKey', + getParentRoute: () => rootRouteImport, +} as any) +const PluginsPluginIdSplatRoute = PluginsPluginIdSplatRouteImport.update({ + id: '/plugins/$pluginId/$', + path: '/plugins/$pluginId/$', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/api-keys': typeof ApiKeysRoute + '/connections': typeof ConnectionsRoute + '/policies': typeof PoliciesRoute + '/secrets': typeof SecretsRoute + '/tools': typeof ToolsRoute + '/join/$code': typeof JoinCodeRoute + '/resume/$executionId': typeof ResumeExecutionIdRoute + '/sources/$namespace': typeof SourcesNamespaceRoute + '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute + '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/api-keys': typeof ApiKeysRoute + '/connections': typeof ConnectionsRoute + '/policies': typeof PoliciesRoute + '/secrets': typeof SecretsRoute + '/tools': typeof ToolsRoute + '/join/$code': typeof JoinCodeRoute + '/resume/$executionId': typeof ResumeExecutionIdRoute + '/sources/$namespace': typeof SourcesNamespaceRoute + '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute + '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/api-keys': typeof ApiKeysRoute + '/connections': typeof ConnectionsRoute + '/policies': typeof PoliciesRoute + '/secrets': typeof SecretsRoute + '/tools': typeof ToolsRoute + '/join/$code': typeof JoinCodeRoute + '/resume/$executionId': typeof ResumeExecutionIdRoute + '/sources/$namespace': typeof SourcesNamespaceRoute + '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute + '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/admin' + | '/api-keys' + | '/connections' + | '/policies' + | '/secrets' + | '/tools' + | '/join/$code' + | '/resume/$executionId' + | '/sources/$namespace' + | '/plugins/$pluginId/$' + | '/sources/add/$pluginKey' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/admin' + | '/api-keys' + | '/connections' + | '/policies' + | '/secrets' + | '/tools' + | '/join/$code' + | '/resume/$executionId' + | '/sources/$namespace' + | '/plugins/$pluginId/$' + | '/sources/add/$pluginKey' + id: + | '__root__' + | '/' + | '/admin' + | '/api-keys' + | '/connections' + | '/policies' + | '/secrets' + | '/tools' + | '/join/$code' + | '/resume/$executionId' + | '/sources/$namespace' + | '/plugins/$pluginId/$' + | '/sources/add/$pluginKey' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AdminRoute: typeof AdminRoute + ApiKeysRoute: typeof ApiKeysRoute + ConnectionsRoute: typeof ConnectionsRoute + PoliciesRoute: typeof PoliciesRoute + SecretsRoute: typeof SecretsRoute + ToolsRoute: typeof ToolsRoute + JoinCodeRoute: typeof JoinCodeRoute + ResumeExecutionIdRoute: typeof ResumeExecutionIdRoute + SourcesNamespaceRoute: typeof SourcesNamespaceRoute + PluginsPluginIdSplatRoute: typeof PluginsPluginIdSplatRoute + SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/tools': { + id: '/tools' + path: '/tools' + fullPath: '/tools' + preLoaderRoute: typeof ToolsRouteImport + parentRoute: typeof rootRouteImport + } + '/secrets': { + id: '/secrets' + path: '/secrets' + fullPath: '/secrets' + preLoaderRoute: typeof SecretsRouteImport + parentRoute: typeof rootRouteImport + } + '/policies': { + id: '/policies' + path: '/policies' + fullPath: '/policies' + preLoaderRoute: typeof PoliciesRouteImport + parentRoute: typeof rootRouteImport + } + '/connections': { + id: '/connections' + path: '/connections' + fullPath: '/connections' + preLoaderRoute: typeof ConnectionsRouteImport + parentRoute: typeof rootRouteImport + } + '/api-keys': { + id: '/api-keys' + path: '/api-keys' + fullPath: '/api-keys' + preLoaderRoute: typeof ApiKeysRouteImport + parentRoute: typeof rootRouteImport + } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/sources/$namespace': { + id: '/sources/$namespace' + path: '/sources/$namespace' + fullPath: '/sources/$namespace' + preLoaderRoute: typeof SourcesNamespaceRouteImport + parentRoute: typeof rootRouteImport + } + '/resume/$executionId': { + id: '/resume/$executionId' + path: '/resume/$executionId' + fullPath: '/resume/$executionId' + preLoaderRoute: typeof ResumeExecutionIdRouteImport + parentRoute: typeof rootRouteImport + } + '/join/$code': { + id: '/join/$code' + path: '/join/$code' + fullPath: '/join/$code' + preLoaderRoute: typeof JoinCodeRouteImport + parentRoute: typeof rootRouteImport + } + '/sources/add/$pluginKey': { + id: '/sources/add/$pluginKey' + path: '/sources/add/$pluginKey' + fullPath: '/sources/add/$pluginKey' + preLoaderRoute: typeof SourcesAddPluginKeyRouteImport + parentRoute: typeof rootRouteImport + } + '/plugins/$pluginId/$': { + id: '/plugins/$pluginId/$' + path: '/plugins/$pluginId/$' + fullPath: '/plugins/$pluginId/$' + preLoaderRoute: typeof PluginsPluginIdSplatRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AdminRoute: AdminRoute, + ApiKeysRoute: ApiKeysRoute, + ConnectionsRoute: ConnectionsRoute, + PoliciesRoute: PoliciesRoute, + SecretsRoute: SecretsRoute, + ToolsRoute: ToolsRoute, + JoinCodeRoute: JoinCodeRoute, + ResumeExecutionIdRoute: ResumeExecutionIdRoute, + SourcesNamespaceRoute: SourcesNamespaceRoute, + PluginsPluginIdSplatRoute: PluginsPluginIdSplatRoute, + SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/apps/host-selfhost/web/router.tsx b/apps/host-selfhost/web/router.tsx new file mode 100644 index 000000000..0d1f42651 --- /dev/null +++ b/apps/host-selfhost/web/router.tsx @@ -0,0 +1,10 @@ +import { createRouter } from "@tanstack/react-router"; + +import { routeTree } from "./routeTree.gen"; + +export const getRouter = () => + createRouter({ + routeTree, + scrollRestoration: true, + defaultPreloadStaleTime: 0, + }); diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx new file mode 100644 index 000000000..aff13915c --- /dev/null +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -0,0 +1,93 @@ +import { createRootRoute, Outlet, useRouterState } from "@tanstack/react-router"; +import { useEffect, useState, type ReactNode } from "react"; + +import { ExecutorProvider } from "@executor-js/react/api/provider"; +import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { Toaster } from "@executor-js/react/components/sonner"; +import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context"; +import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; +import { plugins as clientPlugins } from "virtual:executor/plugins-client"; + +import { authClient } from "../auth-client"; +import { LoginPage } from "../login"; +import { SetupPage } from "../setup"; +import { fetchNeedsSetup } from "../setup-status"; + +// --------------------------------------------------------------------------- +// Self-host root: the SHARED multiplayer composition with Better Auth as the +// provider. Same shell, pages, and account surface as cloud — the only +// self-host specifics are the login form (email/password) and sign-out (Better +// Auth), injected here. No billing, Sentry, or PostHog. +// --------------------------------------------------------------------------- + +export const Route = createRootRoute({ + component: RootComponent, +}); + +// Self-host adds the instance Admin page (members + invite links) to the shared +// nav. The page and its API gate to owner/admin, so a non-admin who opens it +// just sees the access notice. +const selfHostNavItems = [...defaultShellNavItems, { to: "/admin", label: "Admin" }]; + +const signOut = async () => { + await authClient.signOut(); + window.location.href = "/"; +}; + +const Loading = () => ( +
+ Loading… +
+); + +function AuthGate({ children }: { children: ReactNode }) { + const auth = useAuth(); + // When unauthenticated, decide between first-run setup and sign-in by asking + // the server whether the instance still has zero members. `null` = checking. + const [needsSetup, setNeedsSetup] = useState(null); + useEffect(() => { + if (auth.status !== "unauthenticated") return; + let alive = true; + void fetchNeedsSetup().then((value) => { + if (alive) setNeedsSetup(value); + }); + return () => { + alive = false; + }; + }, [auth.status]); + + if (auth.status === "loading") return ; + if (auth.status === "unauthenticated") { + if (needsSetup === null) return ; + return needsSetup ? : ; + } + return <>{children}; +} + +function RootComponent() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + // The join page is public + chromeless: a new user redeeming an invite link + // has no session yet, so it renders outside the auth gate and the shell. + if (pathname.startsWith("/join/")) { + return ( + <> + + + + ); + } + + return ( + + + + + + + + + + + ); +} diff --git a/apps/host-selfhost/web/routes/admin.tsx b/apps/host-selfhost/web/routes/admin.tsx new file mode 100644 index 000000000..89753082f --- /dev/null +++ b/apps/host-selfhost/web/routes/admin.tsx @@ -0,0 +1,251 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { Exit } from "effect"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { useAtom, useAtomValue } from "@effect/atom-react"; +import { toast } from "@executor-js/react/components/sonner"; + +import { Button } from "@executor-js/react/components/button"; +import { CopyButton } from "@executor-js/react/components/copy-button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; +import { + orgMembersAtom, + removeMember, + updateMemberRole, +} from "@executor-js/react/api/account-atoms"; +import { orgMemberWriteKeys } from "@executor-js/react/api/reactivity-keys"; + +import { createInvite, invitesAtom, inviteWriteKeys, revokeInvite } from "../admin-atoms"; + +export const Route = createFileRoute("/admin")({ + component: AdminPage, +}); + +const ROLES = ["member", "admin"] as const; + +// Instance admin console. Members reuse the shared account atoms; invite codes +// are the self-host join mechanism. The API gates to owner/admin, so a +// non-admin who opens this just sees load failures. +function AdminPage() { + return ( +
+
+
+

Admin

+

+ Manage members and invite links for this instance. +

+
+ + +
+
+ ); +} + +function MembersSection() { + const result = useAtomValue(orgMembersAtom); + const [roleState, doUpdateRole] = useAtom(updateMemberRole, { mode: "promiseExit" }); + const [removeState, doRemove] = useAtom(removeMember, { mode: "promiseExit" }); + // The mutation atoms carry their own in-flight state — no manual busy tracking. + const busy = AsyncResult.isWaiting(roleState) || AsyncResult.isWaiting(removeState); + + const changeRole = async (membershipId: string, roleSlug: string) => { + const exit = await doUpdateRole({ + params: { membershipId }, + payload: { roleSlug }, + reactivityKeys: orgMemberWriteKeys, + }); + toast[Exit.isSuccess(exit) ? "success" : "error"]( + Exit.isSuccess(exit) ? "Role updated" : "Failed to update role", + ); + }; + + const remove = async (membershipId: string, label: string) => { + const exit = await doRemove({ params: { membershipId }, reactivityKeys: orgMemberWriteKeys }); + toast[Exit.isSuccess(exit) ? "success" : "error"]( + Exit.isSuccess(exit) ? `Removed ${label}` : "Failed to remove member", + ); + }; + + return ( +
+

Members

+ {AsyncResult.match(result, { + onInitial: () => Loading members…, + onFailure: () => Admin access required., + onSuccess: ({ value }) => ( +
+ {value.members.map((member) => { + const isOwner = member.role === "owner"; + return ( +
+
+

+ {member.name ?? member.email} + {member.isCurrentUser ? " (you)" : ""} +

+

{member.email}

+
+ {isOwner || member.isCurrentUser ? ( + + {member.role} + + ) : ( + <> + changeRole(member.id, e.target.value)} + > + {ROLES.map((role) => ( + + {role} + + ))} + + + + )} +
+ ); + })} +
+ ), + })} +
+ ); +} + +function InvitesSection() { + const result = useAtomValue(invitesAtom); + const [createState, doCreate] = useAtom(createInvite, { mode: "promiseExit" }); + const [, doRevoke] = useAtom(revokeInvite, { mode: "promiseExit" }); + const [role, setRole] = useState("member"); + const [label, setLabel] = useState(""); + const creating = AsyncResult.isWaiting(createState); + + const create = async () => { + const exit = await doCreate({ + payload: { role, label: label.trim() || undefined }, + reactivityKeys: inviteWriteKeys, + }); + if (Exit.isSuccess(exit)) { + setLabel(""); + setRole("member"); + toast.success("Invite link created"); + return; + } + toast.error("Failed to create invite"); + }; + + const revoke = async (inviteId: string) => { + const exit = await doRevoke({ params: { inviteId }, reactivityKeys: inviteWriteKeys }); + toast[Exit.isSuccess(exit) ? "success" : "error"]( + Exit.isSuccess(exit) ? "Invite revoked" : "Failed to revoke invite", + ); + }; + + return ( +
+

Invite links

+
+
+ + setLabel(e.target.value)} + /> +
+
+ + setRole(e.target.value)}> + {ROLES.map((r) => ( + + {r} + + ))} + +
+ +
+ + {AsyncResult.match(result, { + onInitial: () => Loading invites…, + onFailure: () => Admin access required., + onSuccess: ({ value }) => { + const pending = value.invites.filter((i) => !i.usedAt); + const used = value.invites.filter((i) => i.usedAt); + return ( +
+ {pending.length > 0 && ( +
+ {pending.map((invite) => ( +
+ {invite.code} + + {invite.label ? `${invite.label} · ` : ""} + {invite.role} + + + +
+ ))} +
+ )} + {used.length > 0 && ( +
+

Redeemed

+
+ {used.map((invite) => ( +
+ + {invite.code} + + + {invite.label ? `${invite.label} · ` : ""} + used by {invite.usedByEmail} + +
+ ))} +
+
+ )} + {pending.length === 0 && used.length === 0 && ( + No invite links yet — create one to add someone. + )} +
+ ); + }, + })} +
+ ); +} + +function Notice({ children, tone }: { children: React.ReactNode; tone?: "destructive" }) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/host-selfhost/web/routes/api-keys.tsx b/apps/host-selfhost/web/routes/api-keys.tsx new file mode 100644 index 000000000..b563862d7 --- /dev/null +++ b/apps/host-selfhost/web/routes/api-keys.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ApiKeysPage } from "@executor-js/react/pages/api-keys"; + +export const Route = createFileRoute("/api-keys")({ + component: ApiKeysPage, +}); diff --git a/apps/host-selfhost/web/routes/connections.tsx b/apps/host-selfhost/web/routes/connections.tsx new file mode 100644 index 000000000..ae9f0af5a --- /dev/null +++ b/apps/host-selfhost/web/routes/connections.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ConnectionsPage } from "@executor-js/react/pages/connections"; + +export const Route = createFileRoute("/connections")({ + component: () => , +}); diff --git a/apps/host-selfhost/web/routes/index.tsx b/apps/host-selfhost/web/routes/index.tsx new file mode 100644 index 000000000..01273b87a --- /dev/null +++ b/apps/host-selfhost/web/routes/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SourcesPage } from "@executor-js/react/pages/sources"; + +export const Route = createFileRoute("/")({ + component: SourcesPage, +}); diff --git a/apps/host-selfhost/web/routes/join.$code.tsx b/apps/host-selfhost/web/routes/join.$code.tsx new file mode 100644 index 000000000..f8394bb53 --- /dev/null +++ b/apps/host-selfhost/web/routes/join.$code.tsx @@ -0,0 +1,104 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useState, type FormEvent } from "react"; + +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; + +import { authClient } from "../auth-client"; + +export const Route = createFileRoute("/join/$code")({ + component: JoinPage, +}); + +// Public, chromeless account-creation page. Reached at /join/: the code +// is the credential that lets a new person self-register. It rides on the +// signup request body; the server's create gate validates + burns it and drops +// the new user into the org as a member. The root renders this outside the +// auth gate (an un-redeemed visitor has no session yet). +function JoinPage() { + const { code } = Route.useParams(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + // The Better Auth client forwards `inviteCode` (a non-schema field) onto the + // signup body the create gate reads; same-origin, so the session cookie + // sticks. Returns `{ error }` rather than throwing — no manual fetch. + const result = await authClient.signUp.email({ name, email, password, inviteCode: code }); + if (result.error) { + setBusy(false); + setError( + result.error.message ?? + "Could not create your account. Check your invite link and try again.", + ); + return; + } + window.location.href = "/"; + }; + + return ( +
+
+
+

Join Executor

+

+ You've been invited — create your account. +

+
+ +
+ + setName((e.target as HTMLInputElement).value)} + autoComplete="name" + required + /> +
+
+ + setEmail((e.target as HTMLInputElement).value)} + autoComplete="email" + required + /> +
+
+ + setPassword((e.target as HTMLInputElement).value)} + autoComplete="new-password" + required + minLength={8} + /> +
+ + {error &&

{error}

} + + +
+
+ ); +} diff --git a/apps/host-selfhost/web/routes/plugins.$pluginId.$.tsx b/apps/host-selfhost/web/routes/plugins.$pluginId.$.tsx new file mode 100644 index 000000000..64f59a40a --- /dev/null +++ b/apps/host-selfhost/web/routes/plugins.$pluginId.$.tsx @@ -0,0 +1,31 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { useClientPlugins } from "@executor-js/sdk/client"; + +// /plugins// — mounts pages contributed by client plugins, +// materialised from `virtual:executor/plugins-client` via the root's +// ExecutorPluginsProvider. Adding a plugin to executor.config.ts is enough. + +export const Route = createFileRoute("/plugins/$pluginId/$")({ + component: PluginRouteComponent, +}); + +function normalizePath(input: string): string { + if (!input || input === "/") return "/"; + return input.startsWith("/") ? input : `/${input}`; +} + +function PluginRouteComponent() { + const { pluginId, _splat: rest } = Route.useParams(); + const plugins = useClientPlugins(); + const plugin = plugins.find((p) => p.id === pluginId); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() + if (!plugin) throw notFound(); + + const target = normalizePath(rest ?? "/"); + const page = plugin.pages?.find((p) => normalizePath(p.path) === target); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router represents not-found from components by throwing notFound() + if (!page) throw notFound(); + + const Component = page.component; + return ; +} diff --git a/apps/host-selfhost/web/routes/policies.tsx b/apps/host-selfhost/web/routes/policies.tsx new file mode 100644 index 000000000..a9de9ff6f --- /dev/null +++ b/apps/host-selfhost/web/routes/policies.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PoliciesPage } from "@executor-js/react/pages/policies"; + +export const Route = createFileRoute("/policies")({ + component: () => , +}); diff --git a/apps/host-selfhost/web/routes/resume.$executionId.tsx b/apps/host-selfhost/web/routes/resume.$executionId.tsx new file mode 100644 index 000000000..32a84347b --- /dev/null +++ b/apps/host-selfhost/web/routes/resume.$executionId.tsx @@ -0,0 +1,117 @@ +import { useCallback } from "react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Data, Effect, Option, Schema } from "effect"; +import * as Atom from "effect/unstable/reactivity/Atom"; +import { createFileRoute } from "@tanstack/react-router"; +import { + ResumeApprovalPage, + ResumeApprovalPageView, +} from "@executor-js/react/pages/resume-approval"; +import { pausedExecutionAtom } from "@executor-js/react/api/atoms"; +import type { ElicitationAction } from "@executor-js/react/components/elicitation-approval"; + +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + mcp_session_id: Schema.optional(Schema.String), + }), +); +const LocalMcpResumeCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + text: Schema.String, + structured: Schema.Unknown, + isError: Schema.Boolean, +}); +const LocalMcpResumePaused = Schema.Struct({ + status: Schema.Literal("paused"), + text: Schema.String, + structured: Schema.Unknown, +}); +const LocalMcpResumeResult = Schema.Union([LocalMcpResumeCompleted, LocalMcpResumePaused]); +const decodeLocalMcpResumeResult = Schema.decodeUnknownOption(LocalMcpResumeResult); + +class LocalMcpResumeError extends Data.TaggedError("LocalMcpResumeError")<{ + readonly message: string; +}> {} + +type LocalMcpResumeInput = { + readonly mcpSessionId: string; + readonly executionId: string; + readonly action: ElicitationAction; + readonly content?: Record; +}; + +const resumeLocalMcpExecution = Atom.fn()((input) => + Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: () => + fetch( + `/api/mcp-sessions/${encodeURIComponent(input.mcpSessionId)}/executions/${encodeURIComponent(input.executionId)}/resume`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + input.action === "accept" + ? { action: input.action, content: input.content ?? {} } + : { action: input.action }, + ), + }, + ), + catch: () => new LocalMcpResumeError({ message: "Failed to submit approval." }), + }); + + if (!response.ok) { + const body = yield* Effect.tryPromise({ + try: () => response.text(), + catch: () => "", + }).pipe(Effect.orElseSucceed(() => "")); + return yield* new LocalMcpResumeError({ + message: body || `Approval request failed (${response.status}).`, + }); + } + + const body = yield* Effect.tryPromise({ + try: () => response.json(), + catch: () => new LocalMcpResumeError({ message: "Approval response was not valid JSON." }), + }); + const result = decodeLocalMcpResumeResult(body); + if (Option.isNone(result)) { + return yield* new LocalMcpResumeError({ + message: "Approval response had an unexpected shape.", + }); + } + return result.value; + }), +); + +export const Route = createFileRoute("/resume/$executionId")({ + validateSearch: SearchParams, + component: RouteComponent, +}); + +function RouteComponent() { + const { executionId } = Route.useParams(); + const { mcp_session_id: mcpSessionId } = Route.useSearch(); + if (mcpSessionId) { + return ; + } + return ; +} + +function LocalMcpResumeApproval(props: { executionId: string; mcpSessionId: string }) { + const paused = useAtomValue(pausedExecutionAtom(props.executionId)); + const doResume = useAtomSet(resumeLocalMcpExecution, { mode: "promiseExit" }); + const resume = useCallback( + (executionId: string, action: ElicitationAction, content?: Record) => + doResume({ mcpSessionId: props.mcpSessionId, executionId, action, content }), + [doResume, props.mcpSessionId], + ); + + return ( + + ); +} diff --git a/apps/host-selfhost/web/routes/secrets.tsx b/apps/host-selfhost/web/routes/secrets.tsx new file mode 100644 index 000000000..190789172 --- /dev/null +++ b/apps/host-selfhost/web/routes/secrets.tsx @@ -0,0 +1,23 @@ +import { Schema } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { SecretsPage } from "@executor-js/react/pages/secrets"; + +// Query params from the agent-facing `secrets.create` static tool: it builds a +// URL like `/secrets?name=…&scope=…&secretId=…`; open the add modal pre-filled. +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + name: Schema.optional(Schema.String), + secretId: Schema.optional(Schema.String), + provider: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + }), +); + +export const Route = createFileRoute("/secrets")({ + validateSearch: SearchParams, + component: () => { + const { name, secretId, provider, scope } = Route.useSearch(); + const hasPrefill = name != null || secretId != null; + return ; + }, +}); diff --git a/apps/host-selfhost/web/routes/sources.$namespace.tsx b/apps/host-selfhost/web/routes/sources.$namespace.tsx new file mode 100644 index 000000000..2bcdcce73 --- /dev/null +++ b/apps/host-selfhost/web/routes/sources.$namespace.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SourceDetailPage } from "@executor-js/react/pages/source-detail"; + +export const Route = createFileRoute("/sources/$namespace")({ + component: () => { + const { namespace } = Route.useParams(); + return ; + }, +}); diff --git a/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx b/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx new file mode 100644 index 000000000..48d58b32d --- /dev/null +++ b/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx @@ -0,0 +1,20 @@ +import { Schema } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { SourcesAddPage } from "@executor-js/react/pages/sources-add"; + +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + url: Schema.optional(Schema.String), + preset: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), + }), +); + +export const Route = createFileRoute("/sources/add/$pluginKey")({ + validateSearch: SearchParams, + component: () => { + const { pluginKey } = Route.useParams(); + const { url, preset, namespace } = Route.useSearch(); + return ; + }, +}); diff --git a/apps/host-selfhost/web/routes/tools.tsx b/apps/host-selfhost/web/routes/tools.tsx new file mode 100644 index 000000000..25929fd2b --- /dev/null +++ b/apps/host-selfhost/web/routes/tools.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ToolsPage } from "@executor-js/react/pages/tools"; + +export const Route = createFileRoute("/tools")({ + component: ToolsPage, +}); diff --git a/apps/host-selfhost/web/setup-status.ts b/apps/host-selfhost/web/setup-status.ts new file mode 100644 index 000000000..a91c154a6 --- /dev/null +++ b/apps/host-selfhost/web/setup-status.ts @@ -0,0 +1,17 @@ +// Pre-login check of whether the instance still needs first-run setup (its one +// org has zero members). Read by the auth gate to choose the setup vs sign-in +// screen. A plain same-origin fetch — the same boundary the /join + setup +// screens use, which run before the atom registry exists. Two-arg `then` keeps +// it Promise.catch-free; any failure falls back to "no setup needed" (sign-in). +export const fetchNeedsSetup = async (): Promise => { + const response = await fetch("/api/setup-status", { credentials: "same-origin" }).then( + (r) => r, + () => null, + ); + if (!response || !response.ok) return false; + const data = (await response.json().then( + (d) => d, + () => ({}), + )) as { needsSetup?: boolean }; + return data.needsSetup === true; +}; diff --git a/apps/host-selfhost/web/setup.tsx b/apps/host-selfhost/web/setup.tsx new file mode 100644 index 000000000..d038538b2 --- /dev/null +++ b/apps/host-selfhost/web/setup.tsx @@ -0,0 +1,92 @@ +import { useState, type FormEvent } from "react"; + +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; + +import { authClient } from "./auth-client"; + +// First-run setup. A fresh instance has no users, so the first visitor creates +// the admin account here. The server admits the first signup into the empty org +// as its owner (no invite code needed); once anyone is a member, signup is +// invite-gated and this page is never shown again. The auth gate renders this +// when /api/setup-status reports the instance still needs setup. +export const SetupPage = () => { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + const result = await authClient.signUp.email({ name, email, password }); + if (result.error) { + setBusy(false); + setError(result.error.message ?? "Could not create the admin account."); + return; + } + window.location.href = "/"; + }; + + return ( +
+
+
+

Set up Executor

+

+ Create the admin account for this instance. +

+
+ +
+ + setName((e.target as HTMLInputElement).value)} + autoComplete="name" + required + /> +
+
+ + setEmail((e.target as HTMLInputElement).value)} + autoComplete="email" + required + /> +
+
+ + setPassword((e.target as HTMLInputElement).value)} + autoComplete="new-password" + required + minLength={8} + /> +
+ + {error &&

{error}

} + + +
+
+ ); +}; diff --git a/apps/local/drizzle.config.ts b/apps/local/drizzle.config.ts index 8eff64606..7c284204b 100644 --- a/apps/local/drizzle.config.ts +++ b/apps/local/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ - schema: "./src/server/executor-schema.ts", + schema: "./src/db/executor-schema.ts", out: "./drizzle", dialect: "sqlite", }); diff --git a/apps/local/package.json b/apps/local/package.json index 72e79a4e5..e0b1c78ea 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -39,6 +39,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", + "@libsql/client": "catalog:", "@modelcontextprotocol/sdk": "^1.12.1", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", diff --git a/apps/local/src/app.ts b/apps/local/src/app.ts new file mode 100644 index 000000000..f9bddc6e8 --- /dev/null +++ b/apps/local/src/app.ts @@ -0,0 +1,122 @@ +import { HttpApiSwagger } from "effect/unstable/httpapi"; +import { Layer } from "effect"; + +import { + composePluginApi, + ExecutorApp, + FixedExecutionProvider, + textFailureStrategy, +} from "@executor-js/api/server"; +import { createExecutionEngine } from "@executor-js/execution"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; + +import { getExecutorBundle, type LocalExecutor } from "./executor"; +import { localIdentityLayer } from "./identity"; +import { ErrorCaptureLive } from "./observability"; + +// =========================================================================== +// The LOCAL Executor app, as ONE `ExecutorApp.make` call. +// +// The whole scenario in 60 seconds: single-user identity (always the one local +// Principal) over a SINGLE boot-built executor scoped to the working directory +// (`-`, with `oauthEndpointUrlPolicy: { allowHttp: true }`), +// QuickJS in-process code execution, console error capture, Swagger at /docs — +// and NO account API, NO usage metering. `diff` against +// `apps/host-selfhost/src/app.ts` is the whole product difference: local serves +// its ONE cwd executor directly (the `fixedExecution` seam) instead of building +// a per-request `[user-org:…, org]` scoped executor from identity. +// +// `ExecutorApp.make` owns the assembly (the fixed-execution middleware wrapping +// the protected API, the extension routes, provideMerge(boot)). This file's job +// is the eager async boot — building the ONE executor + engine — and slotting +// local's seam Layers into the named slots. +// +// What legitimately stays LOCAL-PLATFORM (the thin `serve.ts` Bun shell, NOT +// make()'s job): the socket binding + idleTimeout, static SPA serving (embedded +// /disk/dev-vite), the one-time legacy SQLite import (run inside the boot bundle, +// BEFORE the executor reaches this seam), the single-credential network gate, the +// /mcp + /api/mcp-sessions resume + /api/oauth/await routes (an in-process, +// single-engine MCP handler with a browser-approval store — local's own surface, +// not the shared multi-user McpServingRoutes envelope), and the `/api`-prefix +// stripping. So `mcp` and `account` are OMITTED, and `mountPrefix` is left at +// root (the Bun shell strips `/api` before the handler). +// =========================================================================== + +/** + * The fixed-execution seam: the ONE boot executor + engine + plugin extension + * map, projected under `FixedExecutionProvider`. The executor already holds its + * cwd scope, libSQL db handle, plugins, and `allowHttp` policy (built in + * `executor.ts`), so local supplies no `DbProvider`/`PluginsProvider`/ + * `HostConfig`/`CodeExecutorProvider` seams — the fixed executor is the whole + * execution model. + */ +const localFixedExecutionLayer = (executor: LocalExecutor): Layer.Layer => + Layer.succeed(FixedExecutionProvider)({ + executor, + engine: createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor(), + }), + // The executor IS its own plugin-extension map (`executor[pluginId]`); the + // fixed middleware reads `executor[id]` to satisfy each plugin's + // `*ExtensionService` Tag per request — identical binding to the prior + // `composePluginHandlers(plugins, executor)` boot-bind. + extensions: executor, + }); + +export interface LocalApiHandler { + /** The unified web handler: serves the typed API (at root — the Bun shell strips `/api`) + /docs. */ + readonly handler: (request: Request) => Promise; + readonly dispose: () => Promise; +} + +/** + * Build the local app's API web-handler. Awaits the shared boot bundle (the one + * cwd-scoped executor, after the legacy SQLite import), then composes + * `ExecutorApp.make` over local's seams and binds it to a `fetch`-style handler. + * + * Mirrors self-host's `makeSelfHostApiHandler`: production-unconditional wiring + * (no test-only branches), with `serve.ts`'s `handlers` injection hook as the + * test seam where a test wants to bypass the boot graph. + */ +export const makeLocalApiHandler = async (): Promise => { + const { executor, plugins } = await getExecutorBundle(); + + // Build the fixed-execution seam ONCE (one executor + one engine). The same + // Layer is the `fixedExecution` seam declaration AND lives in `boot` so the + // fixed middleware's residual `FixedExecutionProvider` resolves there — exactly + // as self-host declares `db: SelfHostDbProvider` and puts the handle in `boot`. + const fixedExecution = localFixedExecutionLayer(executor); + + const { toWebHandler } = ExecutorApp.make({ + plugins, + providers: { + // Single-user: always resolves the one local Principal (a real impl, not a + // placeholder). Boot-scoped (`RIdentity = never`), captured once. + identity: localIdentityLayer, + // The ONE boot executor + engine, served directly — local's fixed + // execution model (no per-request scoped-executor rebuild). + fixedExecution, + // account omitted (local has no account API). + // mcp omitted (local's /mcp is its own in-process surface in serve.ts). + errorCapture: ErrorCaptureLive, + }, + extensions: { + // Swagger UI at /docs, over the root-mounted spec (matches the served + // paths — local serves the API at root; the Bun shell strips `/api`). + routes: [HttpApiSwagger.layer(composePluginApi(plugins), { path: "/docs" })], + }, + // No mountPrefix: local serves the typed API at root and the Bun shell + // strips the `/api` prefix before dispatching here. Local renders identity + // failures as text (matching self-host); the single-user provider never + // produces one in practice. + config: { failure: textFailureStrategy }, + // The boot-scoped context provideMerge'd under everything: the identity + // provider (captured once by the fixed-execution middleware) + the fixed + // execution seam (the one executor + engine + extension map). + boot: Layer.merge(localIdentityLayer, fixedExecution), + }); + + const web = toWebHandler(); + return { handler: web.handler, dispose: web.dispose }; +}; diff --git a/apps/local/src/server/auth-tool-failures.test.ts b/apps/local/src/auth-tool-failures.test.ts similarity index 96% rename from apps/local/src/server/auth-tool-failures.test.ts rename to apps/local/src/auth-tool-failures.test.ts index c779d6141..90ab38d87 100644 --- a/apps/local/src/server/auth-tool-failures.test.ts +++ b/apps/local/src/auth-tool-failures.test.ts @@ -29,7 +29,12 @@ import { } from "effect/unstable/httpapi"; import { addGroup, observabilityMiddleware } from "@executor-js/api"; -import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; +import { + CoreHandlers, + ExecutionEngineService, + ExecutorService, + collectTables, +} from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { openApiPlugin } from "@executor-js/plugin-openapi"; @@ -40,10 +45,10 @@ import { } from "@executor-js/plugin-openapi/api"; import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; -import { Scope, ScopeId, collectTables, createExecutor } from "@executor-js/sdk"; +import { Scope, ScopeId, createExecutor } from "@executor-js/sdk"; import { ErrorCaptureLive } from "./observability"; -import { createSqliteFumaDb } from "./sqlite-fumadb"; +import { createSqliteFumaDb } from "./db/sqlite-fumadb"; const TEST_BASE_URL = "http://local.test"; @@ -72,7 +77,7 @@ const startHarness = async (tmpDir: string): Promise => { fileSecretsPlugin({ directory: tmpDir }), ] as const; const sqlite = await createSqliteFumaDb({ - tables: collectTables(plugins), + tables: collectTables(), namespace: "executor_local_auth_tool_failures_test", path: join(tmpDir, "data.db"), }); diff --git a/apps/local/src/server/db-upgrade.test.ts b/apps/local/src/db/db-upgrade.test.ts similarity index 60% rename from apps/local/src/server/db-upgrade.test.ts rename to apps/local/src/db/db-upgrade.test.ts index 0a4c0aa58..8eb4c5b2b 100644 --- a/apps/local/src/server/db-upgrade.test.ts +++ b/apps/local/src/db/db-upgrade.test.ts @@ -5,13 +5,11 @@ // and preserve legacy secret routing rows for the fresh scoped database. import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { openTestDb, runMigrations } from "../testing/libsql-test-db"; import { importLegacySecrets, isPreScopeSchema, @@ -70,9 +68,9 @@ const SCOPED_SCHEMA = ` ); `; -const seed = (path: string, sql: string) => { - const db = new Database(path); - db.exec(sql); +const seed = async (path: string, sql: string) => { + const db = openTestDb(path); + await db.exec(sql); db.close(); }; @@ -87,37 +85,37 @@ afterEach(() => { }); describe("isPreScopeSchema", () => { - it("returns true for a DB with a source table missing scope_id", () => { + it("returns true for a DB with a source table missing scope_id", async () => { const path = join(workDir, "data.db"); - seed(path, PRE_SCOPE_SCHEMA); - expect(isPreScopeSchema(path)).toBe(true); + await seed(path, PRE_SCOPE_SCHEMA); + expect(await isPreScopeSchema(path)).toBe(true); }); - it("returns false for a DB whose source table already has scope_id", () => { + it("returns false for a DB whose source table already has scope_id", async () => { const path = join(workDir, "data.db"); - seed(path, SCOPED_SCHEMA); - expect(isPreScopeSchema(path)).toBe(false); + await seed(path, SCOPED_SCHEMA); + expect(await isPreScopeSchema(path)).toBe(false); }); - it("returns false for a DB with no source table", () => { + it("returns false for a DB with no source table", async () => { const path = join(workDir, "data.db"); - seed(path, "CREATE TABLE unrelated (x TEXT);"); - expect(isPreScopeSchema(path)).toBe(false); + await seed(path, "CREATE TABLE unrelated (x TEXT);"); + expect(await isPreScopeSchema(path)).toBe(false); }); - it("returns false when the DB file doesn't exist", () => { - expect(isPreScopeSchema(join(workDir, "missing.db"))).toBe(false); + it("returns false when the DB file doesn't exist", async () => { + expect(await isPreScopeSchema(join(workDir, "missing.db"))).toBe(false); }); }); describe("moveAsidePreScopeDb", () => { - it("renames data.db + wal/shm siblings and returns the backup path", () => { + it("renames data.db + wal/shm siblings and returns the backup path", async () => { const path = join(workDir, "data.db"); - seed(path, PRE_SCOPE_SCHEMA); + await seed(path, PRE_SCOPE_SCHEMA); writeFileSync(`${path}-wal`, "wal-bytes"); writeFileSync(`${path}-shm`, "shm-bytes"); - const backup = moveAsidePreScopeDb(path); + const backup = await moveAsidePreScopeDb(path); expect(backup).toMatch(/data\.db\.pre-scopes-\d+-[0-9a-f]{8}$/); expect(existsSync(path)).toBe(false); expect(existsSync(`${path}-wal`)).toBe(false); @@ -127,31 +125,29 @@ describe("moveAsidePreScopeDb", () => { expect(existsSync(`${backup}-shm`)).toBe(true); }); - it("is a no-op when the DB already has the scoped schema", () => { + it("is a no-op when the DB already has the scoped schema", async () => { const path = join(workDir, "data.db"); - seed(path, SCOPED_SCHEMA); - expect(moveAsidePreScopeDb(path)).toBeNull(); + await seed(path, SCOPED_SCHEMA); + expect(await moveAsidePreScopeDb(path)).toBeNull(); expect(existsSync(path)).toBe(true); }); - it("is a no-op when the DB doesn't exist yet", () => { - expect(moveAsidePreScopeDb(join(workDir, "missing.db"))).toBeNull(); + it("is a no-op when the DB doesn't exist yet", async () => { + expect(await moveAsidePreScopeDb(join(workDir, "missing.db"))).toBeNull(); }); }); describe("move-aside + fresh migrate end-to-end", () => { - it("lets migrations run cleanly after an old DB is moved aside", () => { + it("lets migrations run cleanly after an old DB is moved aside", async () => { const path = join(workDir, "data.db"); - seed(path, PRE_SCOPE_SCHEMA); + await seed(path, PRE_SCOPE_SCHEMA); - const backup = moveAsidePreScopeDb(path); + const backup = await moveAsidePreScopeDb(path); expect(backup).not.toBeNull(); - const db = new Database(path); - migrate(drizzle(db), { - migrationsFolder: join(import.meta.dirname, "../../drizzle"), - }); - const cols = db.prepare("PRAGMA table_info('source')").all() as ReadonlyArray<{ + await runMigrations(path, join(import.meta.dirname, "../../drizzle")); + const db = openTestDb(path); + const cols = (await db.prepare("PRAGMA table_info('source')").all()) as ReadonlyArray<{ readonly name: string; }>; db.close(); @@ -160,25 +156,19 @@ describe("move-aside + fresh migrate end-to-end", () => { }); describe("readLegacySecrets", () => { - it("returns all rows from a pre-scope DB's secret table", () => { + it("returns all rows from a pre-scope DB's secret table", async () => { const path = join(workDir, "data.db"); - seed(path, PRE_SCOPE_SCHEMA); - const db = new Database(path); - db.prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)").run( - "sec_1", - "GitHub Token", - "onepassword", - 1_700_000_000, - ); - db.prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)").run( - "sec_2", - "Stripe", - "keychain", - 1_700_000_001, - ); + await seed(path, PRE_SCOPE_SCHEMA); + const db = openTestDb(path); + await db + .prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)") + .run("sec_1", "GitHub Token", "onepassword", 1_700_000_000); + await db + .prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)") + .run("sec_2", "Stripe", "keychain", 1_700_000_001); db.close(); - const rows = readLegacySecrets(path); + const rows = await readLegacySecrets(path); expect(rows).toHaveLength(2); expect(rows[0]).toEqual({ id: "sec_1", @@ -188,21 +178,21 @@ describe("readLegacySecrets", () => { }); }); - it("returns [] when the DB has no secret table", () => { + it("returns [] when the DB has no secret table", async () => { const path = join(workDir, "data.db"); - seed(path, "CREATE TABLE unrelated (x TEXT);"); - expect(readLegacySecrets(path)).toEqual([]); + await seed(path, "CREATE TABLE unrelated (x TEXT);"); + expect(await readLegacySecrets(path)).toEqual([]); }); - it("returns [] when the DB file doesn't exist", () => { - expect(readLegacySecrets(join(workDir, "missing.db"))).toEqual([]); + it("returns [] when the DB file doesn't exist", async () => { + expect(await readLegacySecrets(join(workDir, "missing.db"))).toEqual([]); }); }); describe("importLegacySecrets", () => { - const createScopedDb = (path: string): Database => { - const db = new Database(path); - db.exec(` + const createScopedDb = async (path: string) => { + const db = openTestDb(path); + await db.exec(` CREATE TABLE secret ( id TEXT NOT NULL, scope_id TEXT NOT NULL, @@ -215,16 +205,16 @@ describe("importLegacySecrets", () => { return db; }; - it("inserts rows stamped with the given scope id", () => { + it("inserts rows stamped with the given scope id", async () => { const path = join(workDir, "data.db"); - const db = createScopedDb(path); - importLegacySecrets(db, "scope_a", [ + const db = await createScopedDb(path); + await importLegacySecrets(db.client, "scope_a", [ { id: "sec_1", name: "GH", provider: "onepassword", createdAt: 1 }, { id: "sec_2", name: "St", provider: "keychain", createdAt: 2 }, ]); - const rows = db + const rows = await db .prepare("SELECT id, scope_id, name, provider FROM secret ORDER BY id") - .all() as ReadonlyArray<{ id: string; scope_id: string; name: string; provider: string }>; + .all<{ id: string; scope_id: string; name: string; provider: string }>(); db.close(); expect(rows).toHaveLength(2); expect(rows[0]).toEqual({ @@ -236,29 +226,29 @@ describe("importLegacySecrets", () => { expect(rows[1].scope_id).toBe("scope_a"); }); - it("is a no-op with an empty list", () => { + it("is a no-op with an empty list", async () => { const path = join(workDir, "data.db"); - const db = createScopedDb(path); - importLegacySecrets(db, "scope_a", []); - const count = (db.prepare("SELECT COUNT(*) as n FROM secret").get() as { n: number }).n; + const db = await createScopedDb(path); + await importLegacySecrets(db.client, "scope_a", []); + const count = (await db.prepare("SELECT COUNT(*) as n FROM secret").get<{ n: number }>())?.n; db.close(); expect(count).toBe(0); }); - it("uses INSERT OR IGNORE so a second import of the same ids is a no-op", () => { + it("uses INSERT OR IGNORE so a second import of the same ids is a no-op", async () => { const path = join(workDir, "data.db"); - const db = createScopedDb(path); + const db = await createScopedDb(path); const rows = [{ id: "sec_1", name: "GH", provider: "onepassword", createdAt: 1 }]; - importLegacySecrets(db, "scope_a", rows); - db.prepare( - "UPDATE secret SET provider = 'file' WHERE id = 'sec_1' AND scope_id = 'scope_a'", - ).run(); - importLegacySecrets(db, "scope_a", rows); + await importLegacySecrets(db.client, "scope_a", rows); + await db + .prepare("UPDATE secret SET provider = 'file' WHERE id = 'sec_1' AND scope_id = 'scope_a'") + .run(); + await importLegacySecrets(db.client, "scope_a", rows); const provider = ( - db + await db .prepare("SELECT provider FROM secret WHERE id = ? AND scope_id = ?") - .get("sec_1", "scope_a") as { provider: string } - ).provider; + .get<{ provider: string }>("sec_1", "scope_a") + )?.provider; db.close(); expect(provider).toBe("file"); }); diff --git a/apps/local/src/server/db-upgrade.ts b/apps/local/src/db/db-upgrade.ts similarity index 67% rename from apps/local/src/server/db-upgrade.ts rename to apps/local/src/db/db-upgrade.ts index 7457c7a9b..40ff1d66a 100644 --- a/apps/local/src/server/db-upgrade.ts +++ b/apps/local/src/db/db-upgrade.ts @@ -9,30 +9,37 @@ // backup; most never will — the rows are stale tool catalogs they'd // re-fetch anyway. -import { Database } from "bun:sqlite"; +import { type Client } from "@libsql/client"; import { randomBytes } from "node:crypto"; import * as fs from "node:fs"; +import { openLegacyLibsql, queryFirst, queryRows } from "./libsql"; + /** * Returns true when the DB at `dbPath` looks like it was written by a * pre-scope executor — has a `source` table but no `scope_id` column. * Fresh DBs (no `source` table yet) and current DBs both return false. + * + * Reads the legacy on-disk SQLite file through libSQL (same file format); + * readonly intent is enforced by issuing only SELECT/PRAGMA reads. */ -export const isPreScopeSchema = (dbPath: string): boolean => { +export const isPreScopeSchema = async (dbPath: string): Promise => { if (!fs.existsSync(dbPath)) return false; - const db = new Database(dbPath, { readonly: true }); + const client = openLegacyLibsql(dbPath); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local SQLite schema probe must close the DB handle try { - const tableExists = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='source'") - .get(); + const tableExists = await queryFirst( + client, + "SELECT name FROM sqlite_master WHERE type='table' AND name='source'", + ); if (!tableExists) return false; - const columns = db.prepare("PRAGMA table_info('source')").all() as ReadonlyArray<{ - readonly name: string; - }>; + const columns = await queryRows<{ readonly name: string }>( + client, + "PRAGMA table_info('source')", + ); return !columns.some((c) => c.name === "scope_id"); } finally { - db.close(); + client.close(); } }; @@ -41,8 +48,8 @@ export const isPreScopeSchema = (dbPath: string): boolean => { * `.pre-scopes-`. Returns the backup path if anything * was moved, otherwise null. */ -export const moveAsidePreScopeDb = (dbPath: string): string | null => { - if (!isPreScopeSchema(dbPath)) return null; +export const moveAsidePreScopeDb = async (dbPath: string): Promise => { + if (!(await isPreScopeSchema(dbPath))) return null; // Timestamp alone is near-unique; the random suffix makes it actually // unique even if two moves ever land in the same millisecond. const suffix = `${Date.now()}-${randomBytes(4).toString("hex")}`; @@ -71,20 +78,22 @@ export interface LegacySecret { readonly createdAt: number; } -export const readLegacySecrets = (dbPath: string): readonly LegacySecret[] => { +export const readLegacySecrets = async (dbPath: string): Promise => { if (!fs.existsSync(dbPath)) return []; - const db = new Database(dbPath, { readonly: true }); + const client = openLegacyLibsql(dbPath); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local SQLite legacy-row read must close the DB handle try { - const tableExists = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='secret'") - .get(); + const tableExists = await queryFirst( + client, + "SELECT name FROM sqlite_master WHERE type='table' AND name='secret'", + ); if (!tableExists) return []; - return db - .prepare("SELECT id, name, provider, created_at as createdAt FROM secret") - .all() as LegacySecret[]; + return await queryRows( + client, + "SELECT id, name, provider, created_at as createdAt FROM secret", + ); } finally { - db.close(); + client.close(); } }; @@ -93,16 +102,16 @@ export const readLegacySecrets = (dbPath: string): readonly LegacySecret[] => { * stamping the current scope id. Idempotent — uses INSERT OR IGNORE so * a row that the user already re-registered takes precedence. */ -export const importLegacySecrets = ( - db: Database, +export const importLegacySecrets = async ( + client: Client, scopeId: string, secrets: readonly LegacySecret[], -): void => { +): Promise => { if (secrets.length === 0) return; - const stmt = db.prepare( - "INSERT OR IGNORE INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)", - ); for (const s of secrets) { - stmt.run(scopeId, s.id, s.name, s.provider, s.createdAt); + await client.execute({ + sql: "INSERT OR IGNORE INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)", + args: [scopeId, s.id, s.name, s.provider, s.createdAt], + }); } }; diff --git a/apps/local/src/server/embedded-migrations.gen.ts b/apps/local/src/db/embedded-migrations.gen.ts similarity index 100% rename from apps/local/src/server/embedded-migrations.gen.ts rename to apps/local/src/db/embedded-migrations.gen.ts diff --git a/apps/local/src/server/executor-schema.ts b/apps/local/src/db/executor-schema.ts similarity index 100% rename from apps/local/src/server/executor-schema.ts rename to apps/local/src/db/executor-schema.ts diff --git a/apps/local/src/server/google-discovery-openapi-migration.test.ts b/apps/local/src/db/google-discovery-openapi-migration.test.ts similarity index 52% rename from apps/local/src/server/google-discovery-openapi-migration.test.ts rename to apps/local/src/db/google-discovery-openapi-migration.test.ts index c6c547fd0..b66612e96 100644 --- a/apps/local/src/server/google-discovery-openapi-migration.test.ts +++ b/apps/local/src/db/google-discovery-openapi-migration.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { createClient, type Client } from "@libsql/client"; import { Schema } from "effect"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { oneShotMigrateGoogleDiscoveryToOpenApi } from "./google-discovery-openapi-migration"; @@ -24,9 +27,23 @@ const decodeMigratedSourceData = Schema.decodeUnknownSync( ); const decodeMigratedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(MigratedSpec)); -const createMigrationFixture = () => { - const db = new Database(":memory:"); - db.exec(` +// libSQL's `:memory:` opens a SEPARATE in-memory database per connection, so a +// write transaction (used by the one-shot migration) would not see the seeded +// tables. Back the fixture with a temp file so the migration's transaction +// shares the same database — matching local's real on-disk usage. +let fixtureDir: string; + +beforeEach(() => { + fixtureDir = mkdtempSync(join(tmpdir(), "gd-openapi-mig-")); +}); + +afterEach(() => { + rmSync(fixtureDir, { recursive: true, force: true }); +}); + +const createMigrationFixture = async (): Promise => { + const db = createClient({ url: `file:${join(fixtureDir, "data.db")}` }); + await db.executeMultiple(` CREATE TABLE google_discovery_source ( id text NOT NULL, scope_id text NOT NULL, @@ -133,131 +150,148 @@ const createMigrationFixture = () => { }; describe("oneShotMigrateGoogleDiscoveryToOpenApi", () => { - it("moves a Google Discovery source into OpenAPI storage without changing tool ids", () => { - const db = createMigrationFixture(); + it("moves a Google Discovery source into OpenAPI storage without changing tool ids", async () => { + const db = await createMigrationFixture(); const now = 1_700_000_000; const sourceId = "gmail_api"; const scopeId = "local-scope"; const toolId = `${sourceId}.users.messages.list`; - db.prepare( - "INSERT INTO google_discovery_source (id, scope_id, name, config, auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run( - sourceId, - scopeId, - "Gmail API", - encodeJson({ - discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", - service: "gmail", - version: "v1", - rootUrl: "https://gmail.googleapis.com/", - servicePath: "", - }), - "oauth2", - "google-discovery-oauth2-gmail_api", - "client-id-secret", - "client-secret-secret", - encodeJson(["https://www.googleapis.com/auth/gmail.metadata"]), - now, - now, - ); - db.prepare( - "INSERT INTO source (id, scope_id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run( - sourceId, - scopeId, - "googleDiscovery", - "googleDiscovery", - "Gmail API", - null, - 1, - 0, - 1, - now, - now, - ); - db.prepare( - "INSERT INTO google_discovery_binding (id, scope_id, source_id, binding, created_at) VALUES (?, ?, ?, ?, ?)", - ).run( - toolId, - scopeId, - sourceId, - encodeJson({ - method: "get", - pathTemplate: "gmail/v1/users/{userId}/messages", - hasBody: false, - parameters: [ - { - name: "userId", - location: "path", - required: true, - repeated: false, - schema: { type: "string" }, - }, - { - name: "metadataHeaders", - location: "query", - required: false, - repeated: true, - schema: { type: "array", items: { type: "string" } }, + await db.execute({ + sql: "INSERT INTO google_discovery_source (id, scope_id, name, config, auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + args: [ + sourceId, + scopeId, + "Gmail API", + encodeJson({ + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", + service: "gmail", + version: "v1", + rootUrl: "https://gmail.googleapis.com/", + servicePath: "", + }), + "oauth2", + "google-discovery-oauth2-gmail_api", + "client-id-secret", + "client-secret-secret", + encodeJson(["https://www.googleapis.com/auth/gmail.metadata"]), + now, + now, + ], + }); + await db.execute({ + sql: "INSERT INTO source (id, scope_id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + args: [ + sourceId, + scopeId, + "googleDiscovery", + "googleDiscovery", + "Gmail API", + null, + 1, + 0, + 1, + now, + now, + ], + }); + await db.execute({ + sql: "INSERT INTO google_discovery_binding (id, scope_id, source_id, binding, created_at) VALUES (?, ?, ?, ?, ?)", + args: [ + toolId, + scopeId, + sourceId, + encodeJson({ + method: "get", + pathTemplate: "gmail/v1/users/{userId}/messages", + hasBody: false, + parameters: [ + { + name: "userId", + location: "path", + required: true, + repeated: false, + schema: { type: "string" }, + }, + { + name: "metadataHeaders", + location: "query", + required: false, + repeated: true, + schema: { type: "array", items: { type: "string" } }, + }, + ], + }), + now, + ], + }); + await db.execute({ + sql: "INSERT INTO tool (id, scope_id, source_id, plugin_id, name, description, input_schema, output_schema, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + args: [ + toolId, + scopeId, + sourceId, + "googleDiscovery", + "users.messages.list", + "Lists messages.", + encodeJson({ + type: "object", + properties: { + userId: { type: "string" }, + metadataHeaders: { type: "array", items: { type: "string" } }, }, - ], - }), - now, - ); - db.prepare( - "INSERT INTO tool (id, scope_id, source_id, plugin_id, name, description, input_schema, output_schema, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ).run( - toolId, - scopeId, - sourceId, - "googleDiscovery", - "users.messages.list", - "Lists messages.", - encodeJson({ - type: "object", - properties: { - userId: { type: "string" }, - metadataHeaders: { type: "array", items: { type: "string" } }, - }, - }), - encodeJson({ $ref: "#/$defs/ListMessagesResponse" }), - now, - now, - ); - db.prepare( - "INSERT INTO definition (id, scope_id, source_id, plugin_id, name, schema, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - ).run( - `${sourceId}.ListMessagesResponse`, - scopeId, - sourceId, - "googleDiscovery", - "ListMessagesResponse", - encodeJson({ type: "object", properties: { messages: { type: "array" } } }), - now, - ); + }), + encodeJson({ $ref: "#/$defs/ListMessagesResponse" }), + now, + now, + ], + }); + await db.execute({ + sql: "INSERT INTO definition (id, scope_id, source_id, plugin_id, name, schema, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + args: [ + `${sourceId}.ListMessagesResponse`, + scopeId, + sourceId, + "googleDiscovery", + "ListMessagesResponse", + encodeJson({ type: "object", properties: { messages: { type: "array" } } }), + now, + ], + }); - const migrated = oneShotMigrateGoogleDiscoveryToOpenApi(db); + const migrated = await oneShotMigrateGoogleDiscoveryToOpenApi(db); expect(migrated).toBe(1); expect( - db.prepare("SELECT count(*) AS n FROM google_discovery_source").get() as { n: number }, + (await db.execute("SELECT count(*) AS n FROM google_discovery_source")).rows[0], ).toMatchObject({ n: 0 }); expect( - db.prepare("SELECT plugin_id, kind, url, can_refresh FROM source WHERE id = ?").get(sourceId), + ( + await db.execute({ + sql: "SELECT plugin_id, kind, url, can_refresh FROM source WHERE id = ?", + args: [sourceId], + }) + ).rows[0], ).toMatchObject({ plugin_id: "openapi", kind: "openapi", url: "https://gmail.googleapis.com/", can_refresh: 0, }); - expect(db.prepare("SELECT plugin_id FROM tool WHERE id = ?").get(toolId)).toMatchObject({ + expect( + (await db.execute({ sql: "SELECT plugin_id FROM tool WHERE id = ?", args: [toolId] })) + .rows[0], + ).toMatchObject({ plugin_id: "openapi", }); - const sourceStorage = db - .prepare("SELECT data FROM plugin_storage WHERE collection = 'source' AND key = ?") - .get(sourceId) as { data: string }; + // oxlint-disable-next-line executor/no-double-cast -- boundary: the SELECT column is the schema contract for this plugin_storage row read off the libSQL client + const sourceStorage = ( + await db.execute({ + sql: "SELECT data FROM plugin_storage WHERE collection = 'source' AND key = ?", + args: [sourceId], + }) + ).rows[0] as unknown as { data: string }; const sourceData = decodeMigratedSourceData(sourceStorage.data); const spec = decodeMigratedSpec(sourceData.config.spec); const operation = spec.paths["/gmail/v1/users/{userId}/messages"]?.get; @@ -278,13 +312,18 @@ describe("oneShotMigrateGoogleDiscoveryToOpenApi", () => { }); expect( - db.prepare("SELECT key FROM plugin_storage WHERE collection = 'operation'").get(), + (await db.execute("SELECT key FROM plugin_storage WHERE collection = 'operation'")).rows[0], ).toMatchObject({ key: toolId }); - const credentialBindings = db - .prepare( + const credentialBindings = ( + await db.execute( "SELECT slot_key, kind, secret_id, connection_id FROM credential_binding ORDER BY slot_key", ) - .all(); + ).rows.map((row) => ({ + slot_key: row.slot_key, + kind: row.kind, + secret_id: row.secret_id, + connection_id: row.connection_id, + })); expect(credentialBindings).toEqual([ { slot_key: "oauth2:googleoauth2:client-id", diff --git a/apps/local/src/server/google-discovery-openapi-migration.ts b/apps/local/src/db/google-discovery-openapi-migration.ts similarity index 75% rename from apps/local/src/server/google-discovery-openapi-migration.ts rename to apps/local/src/db/google-discovery-openapi-migration.ts index 19424647c..1109a574c 100644 --- a/apps/local/src/server/google-discovery-openapi-migration.ts +++ b/apps/local/src/db/google-discovery-openapi-migration.ts @@ -1,7 +1,9 @@ -import { Database } from "bun:sqlite"; +import { type Client, type InValue } from "@libsql/client"; import { Option, Schema } from "effect"; import { randomBytes } from "node:crypto"; +import { queryFirst, queryRows } from "./libsql"; + const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); const GoogleDiscoveryConfig = Schema.Struct({ @@ -117,17 +119,22 @@ type OpenApiParameter = { const textDecoder = new TextDecoder(); +// libSQL returns BLOB columns as ArrayBuffer (legacy rows stored JSON as bytes), +// TEXT columns as string. Normalize both to text before JSON-decoding. const decodeJsonColumnOption =
( decode: (value: unknown) => Option.Option, - value: string | Uint8Array | null | undefined, + value: string | Uint8Array | ArrayBuffer | null | undefined, ): Option.Option => { if (!value) return Option.none(); - const text = typeof value === "string" ? value : textDecoder.decode(value); + const text = + typeof value === "string" + ? value + : textDecoder.decode(value instanceof ArrayBuffer ? new Uint8Array(value) : value); return decode(text); }; const decodeJsonColumnOrUndefined = ( - value: string | Uint8Array | null | undefined, + value: string | Uint8Array | ArrayBuffer | null | undefined, ): unknown | undefined => Option.getOrUndefined(decodeJsonColumnOption(decodeUnknownJson, value)); const recordFromUnknown = (value: unknown): Record => @@ -229,19 +236,24 @@ const openApiParameters = ( ...(parameter.description ? { description: parameter.description } : {}), })); -export const oneShotMigrateGoogleDiscoveryToOpenApi = (sqlite: Database): number => { - const table = sqlite - .query<{ name: string }, [string]>( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - ) - .get("google_discovery_source"); +// One-shot startup migration over the LIVE libSQL handle (same file the app +// runs on). Each source migrates inside its own write transaction so a failure +// leaves that source atomic; reads and the BEGIN/COMMIT/ROLLBACK block move from +// synchronous bun:sqlite to async `client.execute` / `client.transaction`. +export const oneShotMigrateGoogleDiscoveryToOpenApi = async (client: Client): Promise => { + const table = await queryFirst( + client, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ["google_discovery_source"], + ); if (!table) return 0; - const sources = sqlite - .query("SELECT * FROM google_discovery_source ORDER BY scope_id, id") - .all(); + const sources = await queryRows( + client, + "SELECT * FROM google_discovery_source ORDER BY scope_id, id", + ); let migrated = 0; - const migrateSource = (source: GoogleSourceRow): boolean => { + const migrateSource = async (source: GoogleSourceRow): Promise => { const config = readSourceConfig(source); if (!config) return false; @@ -250,26 +262,27 @@ export const oneShotMigrateGoogleDiscoveryToOpenApi = (sqlite: Database): number const version = nonEmptyStringOrUndefined(config.version) ?? "v1"; const discoveryUrl = nonEmptyStringOrUndefined(config.discoveryUrl); - const bindings = sqlite - .query( - "SELECT * FROM google_discovery_binding WHERE scope_id = ? AND source_id = ? ORDER BY id", - ) - .all(source.scope_id, source.id); + const bindings = await queryRows( + client, + "SELECT * FROM google_discovery_binding WHERE scope_id = ? AND source_id = ? ORDER BY id", + [source.scope_id, source.id], + ); if (bindings.length === 0) return false; const toolRows = new Map( - sqlite - .query( + ( + await queryRows( + client, "SELECT id, name, description, input_schema, output_schema FROM tool WHERE scope_id = ? AND source_id = ?", + [source.scope_id, source.id], ) - .all(source.scope_id, source.id) - .map((row) => [row.id, row] as const), + ).map((row) => [row.id, row] as const), + ); + const definitions = await queryRows( + client, + "SELECT name, schema FROM definition WHERE scope_id = ? AND source_id = ? ORDER BY name", + [source.scope_id, source.id], ); - const definitions = sqlite - .query( - "SELECT name, schema FROM definition WHERE scope_id = ? AND source_id = ? ORDER BY name", - ) - .all(source.scope_id, source.id); const paths: Record> = {}; const operationRows: Array<{ readonly toolId: string; readonly binding: unknown }> = []; @@ -410,16 +423,16 @@ export const oneShotMigrateGoogleDiscoveryToOpenApi = (sqlite: Database): number const credentialBindings: MigratedCredentialBinding[] = []; - const headerRows = sqlite - .query( - "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_header WHERE scope_id = ? AND source_id = ?", - ) - .all(source.scope_id, source.id); - const queryParamRows = sqlite - .query( - "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_query_param WHERE scope_id = ? AND source_id = ?", - ) - .all(source.scope_id, source.id); + const headerRows = await queryRows( + client, + "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_header WHERE scope_id = ? AND source_id = ?", + [source.scope_id, source.id], + ); + const queryParamRows = await queryRows( + client, + "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_query_param WHERE scope_id = ? AND source_id = ?", + [source.scope_id, source.id], + ); const headers = googleCredentialMap(headerRows, openApiHeaderSlot, credentialBindings); const queryParams = googleCredentialMap( queryParamRows, @@ -464,14 +477,14 @@ export const oneShotMigrateGoogleDiscoveryToOpenApi = (sqlite: Database): number }, }; - sqlite.exec("BEGIN IMMEDIATE"); + // Each source migrates atomically: a libSQL write transaction replaces the + // bun:sqlite BEGIN IMMEDIATE / COMMIT / ROLLBACK block. + const tx = await client.transaction("write"); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: one-shot startup migration should leave each source atomic on write failure try { - sqlite - .query( - "INSERT OR REPLACE INTO plugin_storage (plugin_id, collection, key, data, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run( + await tx.execute({ + sql: "INSERT OR REPLACE INTO plugin_storage (plugin_id, collection, key, data, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + args: [ "openapi", "source", source.id, @@ -481,14 +494,13 @@ export const oneShotMigrateGoogleDiscoveryToOpenApi = (sqlite: Database): number randomRowId(), openApiPluginStorageId("source", source.id), source.scope_id, - ); + ] satisfies InValue[], + }); for (const operation of operationRows) { - sqlite - .query( - "INSERT OR REPLACE INTO plugin_storage (plugin_id, collection, key, data, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run( + await tx.execute({ + sql: "INSERT OR REPLACE INTO plugin_storage (plugin_id, collection, key, data, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + args: [ "openapi", "operation", operation.toolId, @@ -502,18 +514,17 @@ export const oneShotMigrateGoogleDiscoveryToOpenApi = (sqlite: Database): number randomRowId(), openApiPluginStorageId("operation", operation.toolId), source.scope_id, - ); + ] satisfies InValue[], + }); } for (const binding of credentialBindings) { const secretId = binding.kind === "secret" ? binding.secretId : null; const secretScopeId = binding.kind === "secret" ? source.scope_id : null; const connectionId = binding.kind === "connection" ? binding.connectionId : null; - sqlite - .query( - "INSERT OR REPLACE INTO credential_binding (plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, secret_scope_id, connection_id, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run( + await tx.execute({ + sql: "INSERT OR REPLACE INTO credential_binding (plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, secret_scope_id, connection_id, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + args: [ "openapi", source.id, source.scope_id, @@ -528,40 +539,42 @@ export const oneShotMigrateGoogleDiscoveryToOpenApi = (sqlite: Database): number randomRowId(), openApiCredentialBindingId(source.scope_id, source.id, binding.slot), source.scope_id, - ); + ] satisfies InValue[], + }); } - sqlite - .query( - "UPDATE source SET plugin_id = ?, kind = ?, url = ?, can_refresh = ?, can_edit = ?, updated_at = ? WHERE scope_id = ? AND id = ?", - ) - .run("openapi", "openapi", baseUrl, 0, 1, now, source.scope_id, source.id); - sqlite - .query("UPDATE tool SET plugin_id = ?, updated_at = ? WHERE scope_id = ? AND source_id = ?") - .run("openapi", now, source.scope_id, source.id); - sqlite - .query("UPDATE definition SET plugin_id = ? WHERE scope_id = ? AND source_id = ?") - .run("openapi", source.scope_id, source.id); - sqlite - .query("DELETE FROM google_discovery_binding WHERE scope_id = ? AND source_id = ?") - .run(source.scope_id, source.id); - sqlite - .query( - "DELETE FROM google_discovery_source_credential_header WHERE scope_id = ? AND source_id = ?", - ) - .run(source.scope_id, source.id); - sqlite - .query( - "DELETE FROM google_discovery_source_credential_query_param WHERE scope_id = ? AND source_id = ?", - ) - .run(source.scope_id, source.id); - sqlite - .query("DELETE FROM google_discovery_source WHERE scope_id = ? AND id = ?") - .run(source.scope_id, source.id); - sqlite.exec("COMMIT"); + await tx.execute({ + sql: "UPDATE source SET plugin_id = ?, kind = ?, url = ?, can_refresh = ?, can_edit = ?, updated_at = ? WHERE scope_id = ? AND id = ?", + args: ["openapi", "openapi", baseUrl, 0, 1, now, source.scope_id, source.id], + }); + await tx.execute({ + sql: "UPDATE tool SET plugin_id = ?, updated_at = ? WHERE scope_id = ? AND source_id = ?", + args: ["openapi", now, source.scope_id, source.id], + }); + await tx.execute({ + sql: "UPDATE definition SET plugin_id = ? WHERE scope_id = ? AND source_id = ?", + args: ["openapi", source.scope_id, source.id], + }); + await tx.execute({ + sql: "DELETE FROM google_discovery_binding WHERE scope_id = ? AND source_id = ?", + args: [source.scope_id, source.id], + }); + await tx.execute({ + sql: "DELETE FROM google_discovery_source_credential_header WHERE scope_id = ? AND source_id = ?", + args: [source.scope_id, source.id], + }); + await tx.execute({ + sql: "DELETE FROM google_discovery_source_credential_query_param WHERE scope_id = ? AND source_id = ?", + args: [source.scope_id, source.id], + }); + await tx.execute({ + sql: "DELETE FROM google_discovery_source WHERE scope_id = ? AND id = ?", + args: [source.scope_id, source.id], + }); + await tx.commit(); } catch (cause) { - sqlite.exec("ROLLBACK"); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: synchronous SQLite migration rolls back then preserves the original startup failure + await tx.rollback(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the migration rolls back then preserves the original startup failure throw cause; } @@ -569,13 +582,13 @@ export const oneShotMigrateGoogleDiscoveryToOpenApi = (sqlite: Database): number }; for (const source of sources) { - if (migrateSource(source)) { + if (await migrateSource(source)) { migrated++; } } if (migrated > 0) { - sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + await client.execute("PRAGMA wal_checkpoint(TRUNCATE)"); } return migrated; }; diff --git a/apps/local/src/db/libsql.ts b/apps/local/src/db/libsql.ts new file mode 100644 index 000000000..2eae6823d --- /dev/null +++ b/apps/local/src/db/libsql.ts @@ -0,0 +1,72 @@ +import { createClient, type Client, type InArgs, type ResultSet } from "@libsql/client"; +import { resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// libSQL connection helpers for the local server. The local CLI/daemon used to +// open a single in-process bun:sqlite handle that drizzle and the legacy +// importers shared; libSQL instead opens a connection per `createClient`, so +// the per-connection PRAGMAs (foreign_keys, WAL) must be re-applied on every +// client (they no longer carry over from one shared handle). These helpers +// centralize the `file:` URL construction and the per-connection PRAGMA set so +// every open site stays consistent. +// +// libSQL reads existing on-disk SQLite files (the legacy pre-FumaDB / pre-scope +// databases) directly via a `file:` URL — same file format — so the one-time +// legacy import/migration path works against the same files, just through the +// async libSQL client instead of synchronous bun:sqlite. +// --------------------------------------------------------------------------- + +/** + * Build a libSQL `file:` URL from a filesystem path. libSQL requires an + * absolute path for `file:` URLs; `:memory:` passes through unchanged. + */ +export const toLibsqlFileUrl = (path: string): string => + path === ":memory:" ? path : `file:${resolve(path)}`; + +/** + * Open a libSQL client for a local on-disk DB and apply the per-connection + * PRAGMAs (foreign_keys + WAL). Used for the long-lived FumaDB handle and the + * live one-shot google-discovery migration. + */ +export const openLocalLibsql = async (path: string): Promise => { + const client = createClient({ url: toLibsqlFileUrl(path) }); + // foreign_keys is strictly per-connection; WAL is a file-level mode set on + // first enabling. Re-apply both since libSQL gives no shared handle. + await client.execute("PRAGMA foreign_keys = ON"); + await client.execute("PRAGMA journal_mode = WAL"); + return client; +}; + +/** + * Open a libSQL client for reading a legacy on-disk SQLite file. Readonly + * intent is enforced by issuing only SELECT/PRAGMA reads (libSQL has no + * per-open readonly flag in the bun:sqlite sense). + */ +export const openLegacyLibsql = (path: string): Client => + createClient({ url: toLibsqlFileUrl(path) }); + +// --------------------------------------------------------------------------- +// Typed query boundary. `@libsql/client` returns rows as the structural `Row` +// type (array-like with named getters). The legacy importers/probes read known +// column shapes off those rows, so this is the single place where the dynamic +// SQLite result is narrowed to the caller's row type — the SQL is the schema +// contract, mirroring what bun:sqlite's `query()` generic provided. +// --------------------------------------------------------------------------- + +const asRows = (result: ResultSet): readonly T[] => + // oxlint-disable-next-line executor/no-double-cast -- boundary: the SQLite result columns are the schema contract for `T`; libSQL's `Row` is structurally the row, narrowed once here + result.rows as unknown as readonly T[]; + +/** Run a SELECT and return its rows narrowed to `T` (the SQL is the contract). */ +export const queryRows = async ( + client: Client, + sql: string, + args?: InArgs, +): Promise => asRows(await client.execute(args ? { sql, args } : sql)); + +/** Run a SELECT and return its first row narrowed to `T`, or undefined. */ +export const queryFirst = async ( + client: Client, + sql: string, + args?: InArgs, +): Promise => (await queryRows(client, sql, args))[0]; diff --git a/apps/local/src/db/migrate-google-discovery-bindings.test.ts b/apps/local/src/db/migrate-google-discovery-bindings.test.ts new file mode 100644 index 000000000..d7ff10b13 --- /dev/null +++ b/apps/local/src/db/migrate-google-discovery-bindings.test.ts @@ -0,0 +1,212 @@ +// End-to-end test for the google-discovery portion of +// `0007_normalize_plugin_secret_refs.sql`. Seeds a +// google_discovery_source row with the legacy json shape (config +// containing auth/credentials), runs the migration, asserts the new +// columns and child tables are populated. + +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { Schema } from "effect"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { openTestDb, runMigrations } from "../testing/libsql-test-db"; +import { PRE_0007_SQL, stampPriorMigrationsApplied } from "../testing/pre-0007-schema"; + +const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); + +const migratedConfig = Schema.Struct({ + auth: Schema.optional(Schema.Unknown), + service: Schema.String, +}); +const decodeMigratedConfig = Schema.decodeUnknownSync(Schema.fromJsonString(migratedConfig)); + +const tempDirs = new Set(); + +const createTempDbPath = () => { + const dir = mkdtempSync(join(tmpdir(), "gd-mig-")); + tempDirs.add(dir); + return join(dir, "test.sqlite"); +}; + +describe("0007_normalize_plugin_secret_refs (google-discovery)", () => { + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs.clear(); + }); + + it("flattens oauth2 auth into columns", async () => { + const dbPath = createTempDbPath(); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); + + await db + .prepare( + "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "drive", + "Drive", + JSON.stringify({ + name: "Drive", + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", + service: "drive", + version: "v3", + rootUrl: "https://www.googleapis.com/", + servicePath: "drive/v3/", + auth: { + kind: "oauth2", + connectionId: "conn-1", + clientIdSecretId: "client-id", + clientSecretSecretId: "client-secret", + scopes: ["https://www.googleapis.com/auth/drive"], + }, + }), + Date.now(), + Date.now(), + ); + + db.close(); + await runMigrations(dbPath, MIGRATIONS_FOLDER); + + const after = openTestDb(dbPath); + const row = (await after + .prepare( + "SELECT auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes, config FROM google_discovery_source WHERE id = ?", + ) + .get("drive")) as Record; + expect(row.auth_kind).toBe("oauth2"); + expect(row.auth_connection_id).toBe("conn-1"); + expect(row.auth_client_id_secret_id).toBe("client-id"); + expect(row.auth_client_secret_secret_id).toBe("client-secret"); + // auth_scopes column is text-typed (string[] gets stored as JSON in sqlite). + expect(row.auth_scopes).toContain("drive"); + // The auth key should be stripped from config json. + const config = decodeMigratedConfig(row.config); + expect(config.auth).toBeUndefined(); + expect(config.service).toBe("drive"); + after.close(); + }); + + it("explodes credentials.headers and queryParams into child rows", async () => { + const dbPath = createTempDbPath(); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); + + await db + .prepare( + "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "with-creds", + "With Creds", + JSON.stringify({ + name: "With Creds", + discoveryUrl: "https://example.com/discovery", + service: "svc", + version: "v1", + rootUrl: "https://example.com/", + servicePath: "svc/v1/", + auth: { kind: "none" }, + credentials: { + headers: { + "X-Static": "literal", + Authorization: { secretId: "tok-secret", prefix: "Bearer " }, + }, + queryParams: { + api_key: { secretId: "key-secret" }, + }, + }, + }), + Date.now(), + Date.now(), + ); + + db.close(); + await runMigrations(dbPath, MIGRATIONS_FOLDER); + + const after = openTestDb(dbPath); + const headers = (await after + .prepare( + "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_header WHERE source_id = ? ORDER BY name", + ) + .all("with-creds")) as ReadonlyArray>; + expect(headers).toHaveLength(2); + const byName = new Map(headers.map((h) => [h.name!, h])); + expect(byName.get("X-Static")).toMatchObject({ + kind: "text", + text_value: "literal", + }); + expect(byName.get("Authorization")).toMatchObject({ + kind: "secret", + secret_id: "tok-secret", + secret_prefix: "Bearer ", + }); + + const params = (await after + .prepare( + "SELECT name, secret_id FROM google_discovery_source_credential_query_param WHERE source_id = ?", + ) + .all("with-creds")) as ReadonlyArray>; + expect(params).toHaveLength(1); + expect(params[0]).toMatchObject({ name: "api_key", secret_id: "key-secret" }); + + after.close(); + }); + + it("survives auth.kind=none with no credentials", async () => { + const dbPath = createTempDbPath(); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); + + await db + .prepare( + "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "bare", + "Bare", + JSON.stringify({ + name: "Bare", + discoveryUrl: "https://example.com/discovery", + service: "svc", + version: "v1", + rootUrl: "https://example.com/", + servicePath: "svc/v1/", + auth: { kind: "none" }, + }), + Date.now(), + Date.now(), + ); + + db.close(); + await runMigrations(dbPath, MIGRATIONS_FOLDER); + + const after = openTestDb(dbPath); + const row = (await after + .prepare( + "SELECT auth_kind, auth_connection_id, auth_scopes FROM google_discovery_source WHERE id = ?", + ) + .get("bare")) as Record; + expect(row.auth_kind).toBe("none"); + expect(row.auth_connection_id).toBeNull(); + + const headerCount = ( + (await after + .prepare( + "SELECT count(*) as n FROM google_discovery_source_credential_header WHERE source_id = ?", + ) + .get("bare")) as { n: number } + ).n; + expect(headerCount).toBe(0); + after.close(); + }); +}); diff --git a/apps/local/src/server/migrate-graphql-bindings.test.ts b/apps/local/src/db/migrate-graphql-bindings.test.ts similarity index 60% rename from apps/local/src/server/migrate-graphql-bindings.test.ts rename to apps/local/src/db/migrate-graphql-bindings.test.ts index 699d71f97..c3f7d563b 100644 --- a/apps/local/src/server/migrate-graphql-bindings.test.ts +++ b/apps/local/src/db/migrate-graphql-bindings.test.ts @@ -4,15 +4,13 @@ // assert the final slot model plus shared credential_binding rows. import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Database } from "bun:sqlite"; import { Schema } from "effect"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema"; +import { openTestDb, runMigrations } from "../testing/libsql-test-db"; +import { PRE_0007_SQL, stampPriorMigrationsApplied } from "../testing/pre-0007-schema"; const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); @@ -51,31 +49,32 @@ afterEach(() => { }); describe("graphql credential migrations", () => { - it("moves auth json connection refs into a connection slot binding", () => { + it("moves auth json connection refs into a connection slot binding", async () => { const dbPath = join(dir, "test.sqlite"); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); - db.prepare( - "INSERT INTO graphql_source (scope_id, id, name, endpoint, auth) VALUES (?, ?, ?, ?, ?)", - ).run( - "default-scope", - "github", - "GitHub", - "https://api.github.com/graphql", - JSON.stringify({ kind: "oauth2", connectionId: "conn-1" }), - ); + await db + .prepare( + "INSERT INTO graphql_source (scope_id, id, name, endpoint, auth) VALUES (?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "github", + "GitHub", + "https://api.github.com/graphql", + JSON.stringify({ kind: "oauth2", connectionId: "conn-1" }), + ); db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = new Database(dbPath, { readonly: true }); + const after = openTestDb(dbPath); const source = decodePluginStorageData( decodePluginStorageRow( - after + await after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) @@ -85,7 +84,7 @@ describe("graphql credential migrations", () => { expect(source.auth.kind).toBe("oauth2"); expect(source.auth.connectionSlot).toBe("auth:oauth2:connection"); const bindings = decodeBindingRows( - after + await after .prepare( "SELECT scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id FROM credential_binding WHERE plugin_id = ? ORDER BY slot_key", ) @@ -104,7 +103,9 @@ describe("graphql credential migrations", () => { }, ]); // Old json column is gone. - const cols = decodeTableInfoRows(after.prepare("PRAGMA table_info('graphql_source')").all()); + const cols = decodeTableInfoRows( + await after.prepare("PRAGMA table_info('graphql_source')").all(), + ); expect(cols.some((c) => c.name === "auth")).toBe(false); expect(cols.some((c) => c.name === "headers")).toBe(false); expect(cols.some((c) => c.name === "query_params")).toBe(false); @@ -112,11 +113,11 @@ describe("graphql credential migrations", () => { after.close(); }); - it("explodes header/query_param json into slots and credential bindings", () => { + it("explodes header/query_param json into slots and credential bindings", async () => { const dbPath = join(dir, "test.sqlite"); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); const headers = { // Literal text header. @@ -130,27 +131,28 @@ describe("graphql credential migrations", () => { api_key: { secretId: "sec-key" }, }; - db.prepare( - "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, query_params, auth) VALUES (?, ?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "example", - "Example", - "https://example.com/graphql", - JSON.stringify(headers), - JSON.stringify(queryParams), - JSON.stringify({ kind: "none" }), - ); + await db + .prepare( + "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, query_params, auth) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "example", + "Example", + "https://example.com/graphql", + JSON.stringify(headers), + JSON.stringify(queryParams), + JSON.stringify({ kind: "none" }), + ); db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = new Database(dbPath, { readonly: true }); + const after = openTestDb(dbPath); const source = decodePluginStorageData( decodePluginStorageRow( - after + await after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) @@ -171,7 +173,7 @@ describe("graphql credential migrations", () => { }); const bindings = decodeBindingRows( - after + await after .prepare( "SELECT scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id FROM credential_binding WHERE plugin_id = ? ORDER BY slot_key", ) @@ -186,83 +188,77 @@ describe("graphql credential migrations", () => { after.close(); }); - it("fails instead of silently collapsing colliding legacy query parameter slots", () => { + it("fails instead of silently collapsing colliding legacy query parameter slots", async () => { const dbPath = join(dir, "test.sqlite"); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); - db.prepare( - "INSERT INTO graphql_source (scope_id, id, name, endpoint, query_params, auth) VALUES (?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "collision", - "Collision", - "https://example.com/graphql", - JSON.stringify({ - api_key: { secretId: "sec-underscore" }, - "api-key": { secretId: "sec-dash" }, - }), - JSON.stringify({ kind: "none" }), - ); + await db + .prepare( + "INSERT INTO graphql_source (scope_id, id, name, endpoint, query_params, auth) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "collision", + "Collision", + "https://example.com/graphql", + JSON.stringify({ + api_key: { secretId: "sec-underscore" }, + "api-key": { secretId: "sec-dash" }, + }), + JSON.stringify({ kind: "none" }), + ); db.close(); - const sqlite = new Database(dbPath); - const drizzleDb = drizzle(sqlite); - expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow(); - sqlite.close(); + await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); }); - it("fails instead of silently collapsing colliding legacy header slots", () => { + it("fails instead of silently collapsing colliding legacy header slots", async () => { const dbPath = join(dir, "test.sqlite"); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); - db.prepare( - "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, auth) VALUES (?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "collision", - "Collision", - "https://example.com/graphql", - JSON.stringify({ - x_token: { secretId: "sec-underscore" }, - "x-token": { secretId: "sec-dash" }, - }), - JSON.stringify({ kind: "none" }), - ); + await db + .prepare( + "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, auth) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "collision", + "Collision", + "https://example.com/graphql", + JSON.stringify({ + x_token: { secretId: "sec-underscore" }, + "x-token": { secretId: "sec-dash" }, + }), + JSON.stringify({ kind: "none" }), + ); db.close(); - const sqlite = new Database(dbPath); - const drizzleDb = drizzle(sqlite); - expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow(); - sqlite.close(); + await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); }); - it("handles graphql_source rows with null json (empty config)", () => { + it("handles graphql_source rows with null json (empty config)", async () => { const dbPath = join(dir, "test.sqlite"); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); - db.prepare("INSERT INTO graphql_source (scope_id, id, name, endpoint) VALUES (?, ?, ?, ?)").run( - "default-scope", - "bare", - "Bare", - "https://bare.example/graphql", - ); + await db + .prepare("INSERT INTO graphql_source (scope_id, id, name, endpoint) VALUES (?, ?, ?, ?)") + .run("default-scope", "bare", "Bare", "https://bare.example/graphql"); db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = new Database(dbPath, { readonly: true }); + const after = openTestDb(dbPath); const source = decodePluginStorageData( decodePluginStorageRow( - after + await after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) @@ -274,23 +270,23 @@ describe("graphql credential migrations", () => { after.close(); }); - it("does not collapse child rows whose source/name pairs share colon-concatenated ids", () => { + it("does not collapse child rows whose source/name pairs share colon-concatenated ids", async () => { const dbPath = join(dir, "test.sqlite"); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); const insert = db.prepare( "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers) VALUES (?, ?, ?, ?, ?)", ); - insert.run( + await insert.run( "default-scope", "a:b", "First", "https://first.example/graphql", JSON.stringify({ c: "first" }), ); - insert.run( + await insert.run( "default-scope", "a", "Second", @@ -299,19 +295,22 @@ describe("graphql credential migrations", () => { ); db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = new Database(dbPath, { readonly: true }); - const rows = after - .prepare( - "SELECT key, data FROM plugin_storage WHERE plugin_id = ? AND collection = ? ORDER BY key", - ) - .all("graphql", "source") - .map((row) => { - const decoded = decodePluginStorageRow(row); - return { key: (row as { key: string }).key, data: decodePluginStorageData(decoded.data) }; - }) as ReadonlyArray<{ + const after = openTestDb(dbPath); + const rows = ( + await after + .prepare( + "SELECT key, data FROM plugin_storage WHERE plugin_id = ? AND collection = ? ORDER BY key", + ) + .all<{ key: string; data: string }>("graphql", "source") + ).map((row) => { + const decoded = decodePluginStorageRow(row); + return { + key: row.key, + data: decodePluginStorageData(decoded.data), + }; + }) as ReadonlyArray<{ readonly key: string; readonly data: { readonly headers: Record }; }>; diff --git a/apps/local/src/server/migrate-mcp-bindings.test.ts b/apps/local/src/db/migrate-mcp-bindings.test.ts similarity index 58% rename from apps/local/src/server/migrate-mcp-bindings.test.ts rename to apps/local/src/db/migrate-mcp-bindings.test.ts index 6b00033a9..b73c63020 100644 --- a/apps/local/src/server/migrate-mcp-bindings.test.ts +++ b/apps/local/src/db/migrate-mcp-bindings.test.ts @@ -4,15 +4,13 @@ // rows. import { afterEach, describe, expect, it } from "@effect/vitest"; -import { Database } from "bun:sqlite"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { Schema } from "effect"; -import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema"; +import { openTestDb, runMigrations } from "../testing/libsql-test-db"; +import { PRE_0007_SQL, stampPriorMigrationsApplied } from "../testing/pre-0007-schema"; const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); @@ -35,39 +33,40 @@ describe("mcp credential migrations", () => { } }); - it("moves header auth into an auth slot and credential binding", () => { + it("moves header auth into an auth slot and credential binding", async () => { const dbPath = makeDbPath(); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); - db.prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ).run( - "default-scope", - "remote-headers", - "Remote Headers", - JSON.stringify({ - transport: "remote", - endpoint: "https://example.com/mcp", - auth: { - kind: "header", - headerName: "X-API-Key", - secretId: "tok-secret", - prefix: "Bearer ", - }, - }), - Date.now(), - ); + await db + .prepare( + "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "remote-headers", + "Remote Headers", + JSON.stringify({ + transport: "remote", + endpoint: "https://example.com/mcp", + auth: { + kind: "header", + headerName: "X-API-Key", + secretId: "tok-secret", + prefix: "Bearer ", + }, + }), + Date.now(), + ); db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = new Database(dbPath, { readonly: true }); + const after = openTestDb(dbPath); const source = decodePluginStorageData( decodePluginStorageRow( - after + await after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) @@ -91,11 +90,11 @@ describe("mcp credential migrations", () => { secretSlot: "auth:header", prefix: "Bearer ", }); - const binding = after + const binding = (await after .prepare( "SELECT slot_key, kind, secret_id FROM credential_binding WHERE plugin_id = ? AND source_id = ? AND slot_key = ?", ) - .get("mcp", "remote-headers", "auth:header") as Record; + .get("mcp", "remote-headers", "auth:header")) as Record; expect(binding).toMatchObject({ slot_key: "auth:header", kind: "secret", @@ -106,46 +105,47 @@ describe("mcp credential migrations", () => { after.close(); }); - it("moves oauth2 auth and request credentials into slots and bindings", () => { + it("moves oauth2 auth and request credentials into slots and bindings", async () => { const dbPath = makeDbPath(); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); - db.prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ).run( - "default-scope", - "remote-oauth", - "Remote OAuth", - JSON.stringify({ - transport: "remote", - endpoint: "https://oauth.example/mcp", - headers: { - "X-Trace": "static", - "X-Token": { secretId: "extra-tok" }, - }, - queryParams: { - org: { secretId: "org-id-secret" }, - }, - auth: { - kind: "oauth2", - connectionId: "conn-1", - clientIdSecretId: "client-id-sec", - clientSecretSecretId: "client-secret-sec", - }, - }), - Date.now(), - ); + await db + .prepare( + "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "remote-oauth", + "Remote OAuth", + JSON.stringify({ + transport: "remote", + endpoint: "https://oauth.example/mcp", + headers: { + "X-Trace": "static", + "X-Token": { secretId: "extra-tok" }, + }, + queryParams: { + org: { secretId: "org-id-secret" }, + }, + auth: { + kind: "oauth2", + connectionId: "conn-1", + clientIdSecretId: "client-id-sec", + clientSecretSecretId: "client-secret-sec", + }, + }), + Date.now(), + ); db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = new Database(dbPath, { readonly: true }); + const after = openTestDb(dbPath); const source = decodePluginStorageData( decodePluginStorageRow( - after + await after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) @@ -165,11 +165,11 @@ describe("mcp credential migrations", () => { clientSecretSlot: "auth:oauth2:client-secret", }); - const authBindings = after + const authBindings = (await after .prepare( "SELECT slot_key, kind, secret_id, connection_id FROM credential_binding WHERE plugin_id = ? AND source_id = ? ORDER BY slot_key", ) - .all("mcp", "remote-oauth") as ReadonlyArray>; + .all("mcp", "remote-oauth")) as ReadonlyArray>; const bySlot = new Map(authBindings.map((binding) => [binding.slot_key, binding])); expect(bySlot.get("auth:oauth2:connection")).toMatchObject({ kind: "connection", @@ -205,64 +205,64 @@ describe("mcp credential migrations", () => { after.close(); }); - it("fails instead of silently collapsing colliding legacy header slots", () => { + it("fails instead of silently collapsing colliding legacy header slots", async () => { const dbPath = makeDbPath(); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); - db.prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ).run( - "default-scope", - "collision", - "Collision", - JSON.stringify({ - transport: "remote", - endpoint: "https://example.com/mcp", - headers: { - x_token: { secretId: "sec-underscore" }, - "x-token": { secretId: "sec-dash" }, - }, - }), - Date.now(), - ); + await db + .prepare( + "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "collision", + "Collision", + JSON.stringify({ + transport: "remote", + endpoint: "https://example.com/mcp", + headers: { + x_token: { secretId: "sec-underscore" }, + "x-token": { secretId: "sec-dash" }, + }, + }), + Date.now(), + ); db.close(); - const sqlite = new Database(dbPath); - const drizzleDb = drizzle(sqlite); - expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow(); - sqlite.close(); + await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); }); - it("leaves stdio sources alone (no auth, no headers, no queryParams)", () => { + it("leaves stdio sources alone (no auth, no headers, no queryParams)", async () => { const dbPath = makeDbPath(); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + const db = openTestDb(dbPath); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); - db.prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ).run( - "default-scope", - "stdio-only", - "Stdio", - JSON.stringify({ - transport: "stdio", - command: "/usr/bin/server", - args: ["--flag"], - }), - Date.now(), - ); + await db + .prepare( + "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "stdio-only", + "Stdio", + JSON.stringify({ + transport: "stdio", + command: "/usr/bin/server", + args: ["--flag"], + }), + Date.now(), + ); db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = new Database(dbPath, { readonly: true }); + const after = openTestDb(dbPath); const source = decodePluginStorageData( decodePluginStorageRow( - after + await after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) diff --git a/apps/local/src/server/migrate-oauth-connections.test.ts b/apps/local/src/db/migrate-oauth-connections.test.ts similarity index 85% rename from apps/local/src/server/migrate-oauth-connections.test.ts rename to apps/local/src/db/migrate-oauth-connections.test.ts index 795467d5e..b0b4f8af4 100644 --- a/apps/local/src/server/migrate-oauth-connections.test.ts +++ b/apps/local/src/db/migrate-oauth-connections.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Database } from "bun:sqlite"; +import { openTestDb, type LibsqlTestDb } from "../testing/libsql-test-db"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,12 +12,12 @@ const REPAIR_MIGRATION = join( ); let workDir: string; -let db: Database; +let db: LibsqlTestDb; -beforeEach(() => { +beforeEach(async () => { workDir = mkdtempSync(join(tmpdir(), "executor-oauth-conn-mig-")); - db = new Database(join(workDir, "data.db")); - db.exec(` + db = openTestDb(join(workDir, "data.db")); + await db.exec(` CREATE TABLE \`connection\` ( \`id\` text NOT NULL, \`scope_id\` text NOT NULL, @@ -55,12 +55,12 @@ const oauthConnectionMigrationSql = () => { }; describe("0008_scoped_credentials_cutover OAuth connection section", () => { - it("rewrites old OAuth provider keys and provider_state into the canonical oauth2 shape", () => { + it("rewrites old OAuth provider keys and provider_state into the canonical oauth2 shape", async () => { const now = Date.now(); - const insert = db.prepare( + const insert = await db.prepare( "INSERT INTO `connection` (id, scope_id, provider, provider_state, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?)", ); - insert.run( + await insert.run( "openapi-conn", "scope-1", "openapi:oauth2", @@ -74,7 +74,7 @@ describe("0008_scoped_credentials_cutover OAuth connection section", () => { "read", now, ); - insert.run( + await insert.run( "mcp-conn", "scope-1", "mcp:oauth2", @@ -89,7 +89,7 @@ describe("0008_scoped_credentials_cutover OAuth connection section", () => { null, now, ); - insert.run( + await insert.run( "google-conn", "scope-1", "google-discovery:oauth2", @@ -102,10 +102,10 @@ describe("0008_scoped_credentials_cutover OAuth connection section", () => { now, ); - db.exec(oauthConnectionMigrationSql()); + await db.exec(oauthConnectionMigrationSql()); const rows = decodeConnectionRows( - db.prepare("SELECT provider, provider_state FROM `connection` ORDER BY id").all(), + await db.prepare("SELECT provider, provider_state FROM `connection` ORDER BY id").all(), ); expect(rows.map((row) => row.provider)).toEqual(["oauth2", "oauth2", "oauth2"]); const [google, mcp, openapi] = rows.map((row) => decodeJsonRecord(row.provider_state)); @@ -132,9 +132,9 @@ describe("0008_scoped_credentials_cutover OAuth connection section", () => { }); describe("0009_repair_openapi_oauth_cutover_residue", () => { - it("repairs already-canonical OpenAPI rows and restores user-scoped OAuth secret bindings", () => { + it("repairs already-canonical OpenAPI rows and restores user-scoped OAuth secret bindings", async () => { const now = Date.now(); - db.exec(` + await db.exec(` CREATE TABLE \`openapi_source\` ( \`id\` text NOT NULL, \`scope_id\` text NOT NULL, @@ -158,7 +158,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { ); `); - db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run( + await db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run( "example_api", "org-1", JSON.stringify({ @@ -170,10 +170,10 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { }), ); - const insertConnection = db.prepare( + const insertConnection = await db.prepare( "INSERT INTO `connection` (id, scope_id, provider, provider_state, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?)", ); - insertConnection.run( + await insertConnection.run( "openapi-oauth2-app-example_api", "org-1", "oauth2", @@ -186,7 +186,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { null, now, ); - insertConnection.run( + await insertConnection.run( "openapi-oauth2-app-example_api", "user-org:user-jd:org-1", "openapi:oauth2", @@ -200,10 +200,10 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { now, ); - const insertBinding = db.prepare( + const insertBinding = await db.prepare( "INSERT INTO `credential_binding` (id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ); - insertBinding.run( + await insertBinding.run( "org-client-id", "org-1", "openapi", @@ -217,7 +217,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { now, now, ); - insertBinding.run( + await insertBinding.run( "org-client-secret", "org-1", "openapi", @@ -231,7 +231,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { now, now, ); - insertBinding.run( + await insertBinding.run( "org-connection", "org-1", "openapi", @@ -245,7 +245,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { now, now, ); - insertBinding.run( + await insertBinding.run( "jd-connection", "user-org:user-jd:org-1", "openapi", @@ -260,18 +260,26 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { now, ); - db.exec(readFileSync(REPAIR_MIGRATION, "utf-8")); + await db.exec(readFileSync(REPAIR_MIGRATION, "utf-8")); const providers = decodeConnectionRows( - db.prepare("SELECT provider, provider_state FROM `connection` ORDER BY scope_id").all(), + await db.prepare("SELECT provider, provider_state FROM `connection` ORDER BY scope_id").all(), ); expect(providers.map((row) => row.provider)).toEqual(["oauth2", "oauth2"]); - const bindings = db - .prepare( - "SELECT scope_id, slot_key, kind, secret_id, connection_id FROM `credential_binding` WHERE source_id = ? ORDER BY scope_id, slot_key", - ) - .all("example_api"); + const bindings = ( + await db + .prepare( + "SELECT scope_id, slot_key, kind, secret_id, connection_id FROM `credential_binding` WHERE source_id = ? ORDER BY scope_id, slot_key", + ) + .all("example_api") + ).map((row) => ({ + scope_id: row.scope_id, + slot_key: row.slot_key, + kind: row.kind, + secret_id: row.secret_id, + connection_id: row.connection_id, + })); expect(bindings).toEqual([ { scope_id: "org-1", diff --git a/apps/local/src/server/migrate-openapi-bindings.test.ts b/apps/local/src/db/migrate-openapi-bindings.test.ts similarity index 71% rename from apps/local/src/server/migrate-openapi-bindings.test.ts rename to apps/local/src/db/migrate-openapi-bindings.test.ts index cc4503100..010c91501 100644 --- a/apps/local/src/server/migrate-openapi-bindings.test.ts +++ b/apps/local/src/db/migrate-openapi-bindings.test.ts @@ -6,15 +6,13 @@ // child rows and shared credential bindings match the old data. import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Database } from "bun:sqlite"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { Schema } from "effect"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema"; +import { LibsqlTestDb, openTestDb, runMigrations } from "../testing/libsql-test-db"; +import { PRE_0007_SQL, stampPriorMigrationsApplied } from "../testing/pre-0007-schema"; const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); @@ -47,7 +45,7 @@ const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(S describe("0007_normalize_plugin_secret_refs (openapi)", () => { let dir: string; let dbPath: string; - let openDatabases: Set; + let openDatabases: Set; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "openapi-mig-")); @@ -63,28 +61,28 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { rmSync(dir, { recursive: true, force: true }); }); - const openDatabase = (...args: ConstructorParameters) => { - const db = new Database(...args); + const openDatabase = (path: string) => { + const db = openTestDb(path); openDatabases.add(db); return db; }; - const closeDatabase = (db: Database) => { + const closeDatabase = (db: LibsqlTestDb) => { db.close(); openDatabases.delete(db); }; - it("moves openapi_source_binding rows into shared credential_binding", () => { + it("moves openapi_source_binding rows into shared credential_binding", async () => { const db = openDatabase(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); // Seed three bindings, one per kind. - const insert = db.prepare( + const insert = await db.prepare( "INSERT INTO openapi_source_binding (id, source_id, source_scope_id, target_scope_id, slot, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ); const now = Date.now(); - insert.run( + await insert.run( "b1", "src", "default-scope", @@ -94,7 +92,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { now, now, ); - insert.run( + await insert.run( "b2", "src", "default-scope", @@ -104,7 +102,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { now, now, ); - insert.run( + await insert.run( "b3", "src", "default-scope", @@ -118,20 +116,19 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { // Need the parent openapi_source row so the source_id FK ergonomics // are satisfied for any cascading delete logic, though the binding // table has no DB-level FK, code paths assume the parent exists. - db.prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", - ).run("default-scope", "src", "Source", "{}", "{}"); + await db + .prepare( + "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", + ) + .run("default-scope", "src", "Source", "{}", "{}"); closeDatabase(db); - const drizzleSqlite = openDatabase(dbPath); - const drizzleDb = drizzle(drizzleSqlite); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); - closeDatabase(drizzleSqlite); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = openDatabase(dbPath, { readonly: true }); + const after = openDatabase(dbPath); const rows = decodeBindingRows( - after + await after .prepare( "SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding ORDER BY id", ) @@ -175,7 +172,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { text_value: null, }); const oldTableCount = decodeCountRow( - after + await after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_binding'", ) @@ -184,10 +181,10 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { expect(oldTableCount.n).toBe(0); }); - it("explodes query_params and specFetchCredentials json into child slot rows", () => { + it("explodes query_params and specFetchCredentials json into child slot rows", async () => { const db = openDatabase(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); const queryParams = { api_key: { secretId: "qp-secret" }, @@ -202,29 +199,28 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { }, }; - db.prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "src", - "Source", - "{}", - JSON.stringify(queryParams), - JSON.stringify(invocationConfig), - ); + await db + .prepare( + "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "src", + "Source", + "{}", + JSON.stringify(queryParams), + JSON.stringify(invocationConfig), + ); closeDatabase(db); - const drizzleSqlite = openDatabase(dbPath); - const drizzleDb = drizzle(drizzleSqlite); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); - closeDatabase(drizzleSqlite); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = openDatabase(dbPath, { readonly: true }); + const after = openDatabase(dbPath); const sourceData = decodePluginStorageData( decodePluginStorageRow( - after + await after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) @@ -253,7 +249,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { slot: "spec_fetch_query_param:token", }); const oldQueryParamTableCount = decodeCountRow( - after + await after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_query_param'", ) @@ -262,7 +258,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { expect(oldQueryParamTableCount.n).toBe(0); const bindings = decodeBindingRows( - after + await after .prepare( "SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding WHERE source_id = ? ORDER BY slot_key", ) @@ -275,7 +271,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { ]); const oldSourceTableCount = decodeCountRow( - after + await after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", ) @@ -284,64 +280,62 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { expect(oldSourceTableCount.n).toBe(0); }); - it("fails instead of silently collapsing colliding legacy query parameter slots", () => { + it("fails instead of silently collapsing colliding legacy query parameter slots", async () => { const db = openDatabase(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); - - db.prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "collision", - "Collision", - "{}", - JSON.stringify({ - api_key: { secretId: "sec-underscore" }, - "api-key": { secretId: "sec-dash" }, - }), - "{}", - ); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); + + await db + .prepare( + "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "collision", + "Collision", + "{}", + JSON.stringify({ + api_key: { secretId: "sec-underscore" }, + "api-key": { secretId: "sec-dash" }, + }), + "{}", + ); closeDatabase(db); - const sqlite = openDatabase(dbPath); - const drizzleDb = drizzle(sqlite); - expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow(); - closeDatabase(sqlite); + await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); }); - it("fails on punctuation collisions that runtime canonicalization would collapse", () => { + it("fails on punctuation collisions that runtime canonicalization would collapse", async () => { const db = openDatabase(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); - - db.prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "punctuation-collision", - "Punctuation Collision", - "{}", - JSON.stringify({ - "X@Token": { secretId: "sec-at" }, - "X-Token": { secretId: "sec-dash" }, - }), - "{}", - ); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); + + await db + .prepare( + "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "punctuation-collision", + "Punctuation Collision", + "{}", + JSON.stringify({ + "X@Token": { secretId: "sec-at" }, + "X-Token": { secretId: "sec-dash" }, + }), + "{}", + ); closeDatabase(db); - const sqlite = openDatabase(dbPath); - const drizzleDb = drizzle(sqlite); - expect(() => migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER })).toThrow(); - closeDatabase(sqlite); + await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); }); - it("rewrites old OpenAPI header and OAuth JSON into slot config plus core bindings", () => { + it("rewrites old OpenAPI header and OAuth JSON into slot config plus core bindings", async () => { const db = openDatabase(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); const headers = { Authorization: { secretId: "header-token", prefix: "Bearer " }, @@ -361,29 +355,28 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { scopes: ["read"], }; - db.prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, headers, oauth2, invocation_config) VALUES (?, ?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "src", - "Source", - "{}", - JSON.stringify(headers), - JSON.stringify(oauth2), - JSON.stringify({}), - ); + await db + .prepare( + "INSERT INTO openapi_source (scope_id, id, name, spec, headers, oauth2, invocation_config) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .run( + "default-scope", + "src", + "Source", + "{}", + JSON.stringify(headers), + JSON.stringify(oauth2), + JSON.stringify({}), + ); closeDatabase(db); - const drizzleSqlite = openDatabase(dbPath); - const drizzleDb = drizzle(drizzleSqlite); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); - closeDatabase(drizzleSqlite); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = openDatabase(dbPath, { readonly: true }); + const after = openDatabase(dbPath); const source = decodePluginStorageData( decodePluginStorageRow( - after + await after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) @@ -401,7 +394,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { "X-Already": { kind: "binding", slot: "header:x-already" }, }); const oldHeaderTableCount = decodeCountRow( - after + await after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_header'", ) @@ -421,7 +414,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { expect(migratedOAuth2).not.toHaveProperty("clientIdSecretId"); const bindings = decodeBindingRows( - after + await after .prepare( "SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding WHERE source_id = ? ORDER BY slot_key", ) @@ -437,7 +430,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { ]); const oldSourceTableCount = decodeCountRow( - after + await after .prepare( "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", ) @@ -446,26 +439,25 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { expect(oldSourceTableCount.n).toBe(0); }); - it("survives empty / missing json on bindings and sources", () => { + it("survives empty / missing json on bindings and sources", async () => { const db = openDatabase(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); + await db.exec(PRE_0007_SQL); + await stampPriorMigrationsApplied(db); // Source with empty invocation_config and no query_params. - db.prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", - ).run("default-scope", "bare", "Bare", "{}", JSON.stringify({})); + await db + .prepare( + "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", + ) + .run("default-scope", "bare", "Bare", "{}", JSON.stringify({})); closeDatabase(db); - const drizzleSqlite = openDatabase(dbPath); - const drizzleDb = drizzle(drizzleSqlite); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); - closeDatabase(drizzleSqlite); + await runMigrations(dbPath, MIGRATIONS_FOLDER); - const after = openDatabase(dbPath, { readonly: true }); + const after = openDatabase(dbPath); const source = decodePluginStorageData( decodePluginStorageRow( - after + await after .prepare( "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", ) diff --git a/apps/local/src/server/migration-nesting.test.ts b/apps/local/src/db/migration-nesting.test.ts similarity index 83% rename from apps/local/src/server/migration-nesting.test.ts rename to apps/local/src/db/migration-nesting.test.ts index fad663c29..c8f2e07a5 100644 --- a/apps/local/src/server/migration-nesting.test.ts +++ b/apps/local/src/db/migration-nesting.test.ts @@ -1,12 +1,12 @@ // Lint: reject migration SQL that nests a single function call too deeply. // -// bun:sqlite's lemon parser stack overflows at PREPARE time when an -// expression nests too deep, and the limit is platform-dependent — the -// macOS-built compiled CLI binary trips around ~40 levels while Linux can -// go further. Our test matrix only runs on Linux today, so a regression -// won't surface in CI; this lint catches the class of bug structurally -// instead. Cap is 20 (well above any legitimate nested-function call we -// have today, well below the macOS bun:sqlite parser limit). +// SQLite's lemon parser stack overflows at PREPARE time when an expression +// nests too deep, and the limit is platform/build-dependent (historically the +// macOS-built compiled CLI tripped around ~40 levels). The runtime is now +// libSQL, which uses the same SQLite parser, so the structural risk persists; +// this lint catches the class of bug regardless of which build runs the +// migration. Cap is 20 (well above any legitimate nested-function call we have +// today, well below the SQLite parser limit). import { describe, expect, it } from "@effect/vitest"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -97,8 +97,7 @@ describe("drizzle migration SQL structural lint", () => { }; // The expectation is `summary.ok === true`. The full `summary` object is // matched (not just `.ok`) so the failure diff prints file/line/fn/depth - // — bun:sqlite's lemon parser stack overflows on the compiled macOS CLI - // binary around depth 40, and the project's test matrix is Linux-only, + // — SQLite's lemon parser stack overflows on deeply nested expressions, // so the diff is the breadcrumb that tells you which migration to // refactor (precompute into a temp table à la 0008's __slug_norm, or // split the expression into multiple shallow steps). diff --git a/apps/local/src/db/sqlite-fumadb.ts b/apps/local/src/db/sqlite-fumadb.ts new file mode 100644 index 000000000..b3d21b9ee --- /dev/null +++ b/apps/local/src/db/sqlite-fumadb.ts @@ -0,0 +1,100 @@ +import { type Client } from "@libsql/client"; +import { Layer } from "effect"; +import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; +import { type FumaDB } from "fumadb"; +import { + createDrizzleRuntimeSchemaFromTables, + createDrizzleRuntimeSchemaSqlFromTables, +} from "fumadb/adapters/drizzle"; +import { type schema as fumaSchema, type RelationsMap } from "fumadb/schema"; + +import { createExecutorFumaDb, DbProvider, type ExecutorDbHandle } from "@executor-js/api/server"; +import type { FumaDb, FumaTables } from "@executor-js/sdk"; + +import { openLocalLibsql } from "./libsql"; + +type SqliteFumaSchema = ReturnType< + typeof fumaSchema> +>; + +export interface SqliteFumaDb { + readonly db: FumaDb>; + readonly fuma: FumaDB[]>; + readonly drizzle: LibSQLDatabase>; + readonly client: Client; + readonly close: () => Promise; +} + +export interface CreateSqliteFumaDbOptions { + readonly tables: TTables; + readonly namespace: string; + readonly version?: string; + readonly path: string; +} + +export const createSqliteFumaDb = async ( + options: CreateSqliteFumaDbOptions, +): Promise> => { + const version = options.version ?? "1.0.0"; + // libSQL opens a connection (not a shared in-process handle), so the + // foreign_keys + WAL PRAGMAs are applied on this connection inside + // openLocalLibsql. + const client = await openLocalLibsql(options.path); + + const schema = createDrizzleRuntimeSchemaFromTables({ + tables: options.tables, + namespace: options.namespace, + version, + provider: "sqlite", + }); + const drizzleDb = drizzle({ client, schema }); + + for (const statement of createDrizzleRuntimeSchemaSqlFromTables({ + tables: options.tables, + namespace: options.namespace, + version, + provider: "sqlite", + })) { + await client.execute(statement); + } + + // Defensive column add for libSQL files created before connection identity + // overrides existed — the bring-up above is CREATE TABLE IF NOT EXISTS and + // won't add a column to an already-created table. Idempotent. + const connectionColumns = await client.execute("PRAGMA table_info('connection')"); + if ( + connectionColumns.rows.length > 0 && + !connectionColumns.rows.some((column) => column["name"] === "identity_override") + ) { + await client.execute("ALTER TABLE connection ADD COLUMN identity_override TEXT"); + } + + const { db, fuma } = createExecutorFumaDb(drizzleDb, { + tables: options.tables, + namespace: options.namespace, + version, + provider: "sqlite", + }); + + return { + db, + fuma, + drizzle: drizzleDb, + client, + close: async () => { + client.close(); + }, + }; +}; + +// Shared DbProvider seam (P2a). Local builds its libSQL handle once at boot +// (driver-open + WAL PRAGMA + the SQL-loop schema bring-up above stay here) and +// then re-exposes it under the shared `DbProvider` tag. The handle's lifecycle +// is owned by the caller's acquireRelease, so this projection's `close` is a +// no-op to avoid double-closing the connection. +export const localDbProviderLayer = (handle: SqliteFumaDb): Layer.Layer => + Layer.succeed(DbProvider)({ + db: handle.db, + fuma: handle.fuma, + close: async () => {}, + } satisfies ExecutorDbHandle); diff --git a/apps/local/src/server/sqlite-import.test.ts b/apps/local/src/db/sqlite-import.test.ts similarity index 83% rename from apps/local/src/server/sqlite-import.test.ts rename to apps/local/src/db/sqlite-import.test.ts index 87649929c..1c8d575ee 100644 --- a/apps/local/src/server/sqlite-import.test.ts +++ b/apps/local/src/db/sqlite-import.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Database } from "bun:sqlite"; +import { type Client } from "@libsql/client"; import { Schema } from "effect"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { collectTables } from "@executor-js/api/server"; import { boolColumn, - collectTables, dateColumn, jsonColumn, nullableBigintColumn, @@ -18,13 +18,14 @@ import { } from "@executor-js/sdk"; import { withQueryContext } from "fumadb/query"; -import { importLegacySqliteIfNeeded, readBundledDrizzleMigrationHashes } from "./executor"; +import { openTestClient, openTestDb } from "../testing/libsql-test-db"; +import { importLegacySqliteIfNeeded, readBundledDrizzleMigrationHashes } from "../executor"; import { importSqliteDataToFuma, readLegacySqliteScopeIds } from "./sqlite-import"; import { createSqliteFumaDb, type SqliteFumaDb } from "./sqlite-fumadb"; let workDir: string; let sqlite: SqliteFumaDb | null; -let heldReader: Database | null; +let heldReader: Client | null; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), "executor-sqlite-import-")); @@ -38,9 +39,9 @@ afterEach(async () => { rmSync(workDir, { recursive: true, force: true }); }); -const seedSqlite = (path: string) => { - const db = new Database(path); - db.exec(` +const seedSqlite = async (path: string) => { + const db = openTestDb(path); + await db.exec(` CREATE TABLE source ( id TEXT PRIMARY KEY NOT NULL, plugin_id TEXT NOT NULL, @@ -60,57 +61,59 @@ const seedSqlite = (path: string) => { PRIMARY KEY (namespace, key) ); `); - db.prepare( - `INSERT INTO source ( + await db + .prepare( + `INSERT INTO source ( id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - "src_1", - "plugin", - "remote", - "Imported", - null, - 1, - 0, - 1, - 1_700_000_000_000, - 1_700_000_001_000, - ); - db.prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)").run( - "scope_a/plugin", - "spec", - "{}", - ); + ) + .run( + "src_1", + "plugin", + "remote", + "Imported", + null, + 1, + 0, + 1, + 1_700_000_000_000, + 1_700_000_001_000, + ); + await db + .prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)") + .run("scope_a/plugin", "spec", "{}"); db.close(); }; -const seedDrizzleMigrationHistory = ( - db: Database, +const seedDrizzleMigrationHistory = async ( + db: ReturnType, hashes: ReadonlyArray = readBundledDrizzleMigrationHashes( join(import.meta.dirname, "../../drizzle"), ), ) => { - db.exec(` + await db.exec(` CREATE TABLE "__drizzle_migrations" ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, hash text NOT NULL, created_at numeric ); `); - const insert = db.prepare(`INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)`); + const insert = await db.prepare( + `INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)`, + ); for (const hash of hashes) { - insert.run(hash, Date.now()); + await insert.run(hash, Date.now()); } }; -const seedMigratedSqlite = ( +const seedMigratedSqlite = async ( path: string, options?: { readonly migrationHashes?: ReadonlyArray; }, ) => { - const db = new Database(path); - db.exec(` + const db = openTestDb(path); + await db.exec(` CREATE TABLE source ( scope_id TEXT NOT NULL, id TEXT NOT NULL, @@ -132,29 +135,29 @@ const seedMigratedSqlite = ( PRIMARY KEY (namespace, key) ); `); - seedDrizzleMigrationHistory(db, options?.migrationHashes); - db.prepare( - `INSERT INTO source ( + await seedDrizzleMigrationHistory(db, options?.migrationHashes); + await db + .prepare( + `INSERT INTO source ( scope_id, id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - "scope_a", - "src_1", - "plugin", - "remote", - "Imported", - null, - 1, - 0, - 1, - 1_700_000_000_000, - 1_700_000_001_000, - ); - db.prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)").run( - "scope_a/plugin", - "spec", - "{}", - ); + ) + .run( + "scope_a", + "src_1", + "plugin", + "remote", + "Imported", + null, + 1, + 0, + 1, + 1_700_000_000_000, + 1_700_000_001_000, + ); + await db + .prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)") + .run("scope_a/plugin", "spec", "{}"); db.close(); }; @@ -185,9 +188,9 @@ describe("importSqliteDataToFuma", () => { it("imports current SQLite rows into FumaDB SQLite without replacing source files", async () => { const sqlitePath = join(workDir, "data.db"); const markerPath = join(workDir, "fumadb-sqlite-imported"); - seedSqlite(sqlitePath); + await seedSqlite(sqlitePath); - const tables = collectTables([]); + const tables = collectTables(); sqlite = await createSqliteFumaDb({ tables, namespace: "executor_local_test", @@ -226,8 +229,8 @@ describe("importSqliteDataToFuma", () => { it("imports every existing legacy scope from the global local database", async () => { const sqlitePath = join(workDir, "data.db"); - const db = new Database(sqlitePath); - db.exec(` + const db = openTestDb(sqlitePath); + await db.exec(` CREATE TABLE source ( scope_id TEXT NOT NULL, id TEXT NOT NULL, @@ -243,12 +246,12 @@ describe("importSqliteDataToFuma", () => { PRIMARY KEY (scope_id, id) ); `); - const insert = db.prepare( + const insert = await db.prepare( `INSERT INTO source ( scope_id, id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); - insert.run( + await insert.run( "scope_a", "src_a", "plugin", @@ -261,7 +264,7 @@ describe("importSqliteDataToFuma", () => { 1_700_000_000_000, 1_700_000_001_000, ); - insert.run( + await insert.run( "scope_b", "src_b", "plugin", @@ -276,8 +279,8 @@ describe("importSqliteDataToFuma", () => { ); db.close(); - const tables = collectTables([]); - const legacyScopeIds = readLegacySqliteScopeIds({ + const tables = collectTables(); + const legacyScopeIds = await readLegacySqliteScopeIds({ sqlitePath, tables, scopeId: "scope_a", @@ -312,8 +315,8 @@ describe("importSqliteDataToFuma", () => { it("normalizes plugin table values when importing legacy SQLite rows", async () => { const sqlitePath = join(workDir, "data.db"); - const db = new Database(sqlitePath); - db.exec(` + const db = openTestDb(sqlitePath); + await db.exec(` CREATE TABLE legacy_shape ( scope_id TEXT NOT NULL, id TEXT NOT NULL, @@ -325,23 +328,25 @@ describe("importSqliteDataToFuma", () => { PRIMARY KEY (scope_id, id) ); `); - db.prepare( - `INSERT INTO legacy_shape ( + await db + .prepare( + `INSERT INTO legacy_shape ( scope_id, id, payload, enabled, retry_after_ms, discovered_at, note ) VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run( - "scope_a", - "shape_1", - JSON.stringify({ auth: { type: "oauth2" }, paths: ["/v1/items"] }), - 1, - "9007199254740993", - 1_700_000_000_000, - null, - ); + ) + .run( + "scope_a", + "shape_1", + JSON.stringify({ auth: { type: "oauth2" }, paths: ["/v1/items"] }), + 1, + "9007199254740993", + 1_700_000_000_000, + null, + ); db.close(); const tables: FumaTables = { - ...collectTables([]), + ...collectTables(), ...legacyShapeSchema, }; sqlite = await createSqliteFumaDb({ @@ -378,9 +383,9 @@ describe("importSqliteDataToFuma", () => { it("writes the import marker only after the replacement database is in place", async () => { const sqlitePath = join(workDir, "data.db"); const markerPath = join(workDir, "fumadb-sqlite-imported"); - seedMigratedSqlite(sqlitePath); + await seedMigratedSqlite(sqlitePath); - const tables = collectTables([]); + const tables = collectTables(); const result = await importLegacySqliteIfNeeded({ storage: { dataDir: workDir, @@ -411,11 +416,11 @@ describe("importSqliteDataToFuma", () => { it("imports an existing legacy schema with divergent Drizzle migration history", async () => { const sqlitePath = join(workDir, "data.db"); const markerPath = join(workDir, "fumadb-sqlite-imported"); - seedMigratedSqlite(sqlitePath, { + await seedMigratedSqlite(sqlitePath, { migrationHashes: ["different-branch-migration", "newer-branch-migration"], }); - const tables = collectTables([]); + const tables = collectTables(); const result = await importLegacySqliteIfNeeded({ storage: { dataDir: workDir, @@ -446,17 +451,19 @@ describe("importSqliteDataToFuma", () => { it("imports a checkpointed legacy WAL database even when DELETE journal mode is busy", async () => { const sqlitePath = join(workDir, "data.db"); const markerPath = join(workDir, "fumadb-sqlite-imported"); - seedMigratedSqlite(sqlitePath); + await seedMigratedSqlite(sqlitePath); - const writer = new Database(sqlitePath); - writer.exec("PRAGMA journal_mode = WAL"); + const writer = openTestClient(sqlitePath); + await writer.execute("PRAGMA journal_mode = WAL"); writer.close(); - heldReader = new Database(sqlitePath, { readonly: true }); - heldReader.exec("BEGIN"); - heldReader.query("SELECT * FROM source").all(); + // Hold a concurrent read on a SEPARATE libSQL connection so the importer's + // WAL checkpoint must contend with an open reader (busy_timeout handling). + heldReader = openTestClient(sqlitePath); + await heldReader.execute("BEGIN"); + await heldReader.execute("SELECT * FROM source"); - const tables = collectTables([]); + const tables = collectTables(); const result = await importLegacySqliteIfNeeded({ storage: { dataDir: workDir, @@ -486,10 +493,10 @@ describe("importSqliteDataToFuma", () => { it("imports newly-available tables from the original backup after the first cutover", async () => { const sqlitePath = join(workDir, "data.db"); const markerPath = join(workDir, "fumadb-sqlite-imported"); - seedMigratedSqlite(sqlitePath); + await seedMigratedSqlite(sqlitePath); - const legacy = new Database(sqlitePath); - legacy.exec(` + const legacy = openTestDb(sqlitePath); + await legacy.exec(` CREATE TABLE late_item ( scope_id TEXT NOT NULL, id TEXT NOT NULL, @@ -502,7 +509,7 @@ describe("importSqliteDataToFuma", () => { .run("scope_a", "late_1", "from-backup"); legacy.close(); - const firstTables = collectTables([]); + const firstTables = collectTables(); const firstResult = await importLegacySqliteIfNeeded({ storage: { dataDir: workDir, @@ -515,7 +522,7 @@ describe("importSqliteDataToFuma", () => { expect(firstResult.importedTables).not.toContain("late_item"); const allTables: FumaTables = { - ...collectTables([]), + ...collectTables(), ...lateSchema, }; const secondResult = await importLegacySqliteIfNeeded({ @@ -546,7 +553,7 @@ describe("importSqliteDataToFuma", () => { it("marks newly-available empty tables so startup does not retry backup imports", async () => { const sqlitePath = join(workDir, "data.db"); const markerPath = join(workDir, "fumadb-sqlite-imported"); - seedMigratedSqlite(sqlitePath); + await seedMigratedSqlite(sqlitePath); const firstResult = await importLegacySqliteIfNeeded({ storage: { @@ -554,13 +561,13 @@ describe("importSqliteDataToFuma", () => { sqlitePath, importMarkerPath: markerPath, }, - tables: collectTables([]), + tables: collectTables(), scopeId: "scope_a", }); expect(firstResult.importedTables).not.toContain("late_item"); const allTables: FumaTables = { - ...collectTables([]), + ...collectTables(), ...lateSchema, }; const secondResult = await importLegacySqliteIfNeeded({ diff --git a/apps/local/src/server/sqlite-import.ts b/apps/local/src/db/sqlite-import.ts similarity index 77% rename from apps/local/src/server/sqlite-import.ts rename to apps/local/src/db/sqlite-import.ts index 71a071d0f..3f0dc0bd1 100644 --- a/apps/local/src/server/sqlite-import.ts +++ b/apps/local/src/db/sqlite-import.ts @@ -1,4 +1,4 @@ -import { Database } from "bun:sqlite"; +import { type Client } from "@libsql/client"; import { Data } from "effect"; import { existsSync } from "node:fs"; @@ -6,6 +6,8 @@ import { existsSync } from "node:fs"; import { type AnyColumn, type AnyTable, type FumaTables } from "@executor-js/sdk"; +import { openLegacyLibsql, queryFirst, queryRows } from "./libsql"; + type SqliteRow = Record; type ImportFumaDb = Readonly<{ @@ -37,32 +39,36 @@ export interface LocalSqliteImportResult { const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; const sqliteStringLiteral = (value: string): string => `'${value.replaceAll("'", "''")}'`; -const tableExists = (sqlite: Database, tableName: string): boolean => { - const row = sqlite - .query<{ name: string }, [string]>( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - ) - .get(tableName); - return row !== null; +const tableExists = async (client: Client, tableName: string): Promise => { + const row = await queryFirst( + client, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + [tableName], + ); + return row != null; }; -const sqliteColumnNames = (sqlite: Database, tableName: string): ReadonlySet => { - const rows = sqlite - .query<{ name: string }, []>(`PRAGMA table_info(${sqliteStringLiteral(tableName)})`) - .all(); +const sqliteColumnNames = async ( + client: Client, + tableName: string, +): Promise> => { + const rows = await queryRows<{ name: string }>( + client, + `PRAGMA table_info(${sqliteStringLiteral(tableName)})`, + ); return new Set(rows.map((row) => row.name)); }; -const readRows = (sqlite: Database, tableName: string): readonly SqliteRow[] => - sqlite.query(`SELECT * FROM ${quoteIdent(tableName)}`).all(); +const readRows = async (client: Client, tableName: string): Promise => + queryRows(client, `SELECT * FROM ${quoteIdent(tableName)}`); -const readScopeIds = (sqlite: Database, tableName: string): readonly string[] => - sqlite - .query<{ scope_id: unknown }, []>( +const readScopeIds = async (client: Client, tableName: string): Promise => + ( + await queryRows<{ scope_id: unknown }>( + client, `SELECT DISTINCT "scope_id" AS scope_id FROM ${quoteIdent(tableName)} WHERE "scope_id" IS NOT NULL`, ) - .all() - .flatMap((row) => (typeof row.scope_id === "string" ? [row.scope_id] : [])); + ).flatMap((row) => (typeof row.scope_id === "string" ? [row.scope_id] : [])); const parseJson = (value: string): unknown => { try { @@ -160,23 +166,23 @@ const toFumaRow = (input: { return out; }; -export const readLegacySqliteScopeIds = (options: { +export const readLegacySqliteScopeIds = async (options: { readonly sqlitePath: string; readonly tables: FumaTables; readonly scopeId: string; -}): ReadonlySet => { +}): Promise> => { const scopeIds = new Set([options.scopeId]); if (!existsSync(options.sqlitePath)) return scopeIds; - let sqlite: Database | null = null; + let client: Client | null = null; try { - sqlite = new Database(options.sqlitePath, { readonly: true }); + client = openLegacyLibsql(options.sqlitePath); for (const table of Object.values(options.tables)) { const tableName = table.names.sql; - if (!tableExists(sqlite, tableName)) continue; - const columns = sqliteColumnNames(sqlite, tableName); + if (!(await tableExists(client, tableName))) continue; + const columns = await sqliteColumnNames(client, tableName); if (!columns.has("scope_id")) continue; - for (const scopeId of readScopeIds(sqlite, tableName)) { + for (const scopeId of await readScopeIds(client, tableName)) { scopeIds.add(scopeId); } } @@ -188,7 +194,7 @@ export const readLegacySqliteScopeIds = (options: { cause, }); } finally { - sqlite?.close(); + client?.close(); } }; @@ -199,20 +205,21 @@ export const importSqliteDataToFuma = async ( return { imported: false, importedRows: 0, importedTables: [] }; } - let sqlite: Database | null = null; + let client: Client | null = null; try { - sqlite = new Database(options.sqlitePath, { readonly: true }); + client = openLegacyLibsql(options.sqlitePath); + const reader = client; const importedTables: string[] = []; let importedRows = 0; await options.target.transaction(async (db) => { for (const [tableKey, table] of Object.entries(options.tables)) { const tableName = table.names.sql; - if (!tableExists(sqlite!, tableName)) continue; + if (!(await tableExists(reader, tableName))) continue; - const sqliteColumns = sqliteColumnNames(sqlite!, tableName); - const rows = readRows(sqlite!, tableName).map((row) => + const sqliteColumns = await sqliteColumnNames(reader, tableName); + const rows = (await readRows(reader, tableName)).map((row) => toFumaRow({ tableKey, table, @@ -229,8 +236,8 @@ export const importSqliteDataToFuma = async ( } }); - sqlite.close(); - sqlite = null; + client.close(); + client = null; return { imported: true, importedRows, importedTables }; } catch (cause) { @@ -240,6 +247,6 @@ export const importSqliteDataToFuma = async ( cause, }); } finally { - sqlite?.close(); + client?.close(); } }; diff --git a/apps/local/src/server/executor.ts b/apps/local/src/executor.ts similarity index 84% rename from apps/local/src/server/executor.ts rename to apps/local/src/executor.ts index ec5a479c9..40091ab71 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/executor.ts @@ -1,7 +1,7 @@ import { Context, Data, Effect, Layer, ManagedRuntime, Schema } from "effect"; -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { type Client } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; import { createHash, randomBytes } from "node:crypto"; import * as fs from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -10,31 +10,32 @@ import { basename, dirname, join } from "node:path"; import { Scope, ScopeId, - collectTables, createExecutor, type AnyPlugin, type Executor, type FumaTables, } from "@executor-js/sdk"; +import { collectTables } from "@executor-js/api/server"; import { withQueryContext } from "fumadb/query"; import { loadPluginsFromJsonc } from "@executor-js/config"; -import executorConfig from "../../executor.config"; -import embeddedMigrations from "./embedded-migrations.gen"; +import executorConfig from "../executor.config"; +import embeddedMigrations from "./db/embedded-migrations.gen"; import { importLegacySecrets, moveAsidePreScopeDb, readLegacySecrets, type LegacySecret, -} from "./db-upgrade"; -import * as legacyExecutorSchema from "./executor-schema"; +} from "./db/db-upgrade"; +import * as legacyExecutorSchema from "./db/executor-schema"; import { importSqliteDataToFuma, readLegacySqliteScopeIds, type LocalSqliteImportResult, -} from "./sqlite-import"; -import { createSqliteFumaDb } from "./sqlite-fumadb"; -import { oneShotMigrateGoogleDiscoveryToOpenApi } from "./google-discovery-openapi-migration"; +} from "./db/sqlite-import"; +import { createSqliteFumaDb } from "./db/sqlite-fumadb"; +import { openLegacyLibsql, queryFirst, queryRows } from "./db/libsql"; +import { oneShotMigrateGoogleDiscoveryToOpenApi } from "./db/google-discovery-openapi-migration"; interface ResolvedStorage { readonly dataDir: string; @@ -49,7 +50,7 @@ const localNamespace = "executor_local"; // temp folder because drizzle's migrator accepts a folder path. const resolveMigrationsFolder = (): string => { if (!embeddedMigrations) { - return join(import.meta.dirname, "../../drizzle"); + return join(import.meta.dirname, "../drizzle"); } const dir = fs.mkdtempSync(join(tmpdir(), "executor-migrations-")); @@ -182,29 +183,39 @@ const handleOrNull = (promise: ReturnType) => ), ); -const sqliteTableHasColumn = (db: Database, table: string, column: string): boolean => - db - .query<{ name: string }, []>(`PRAGMA table_info('${table.replaceAll("'", "''")}')`) - .all() - .some((row) => row.name === column); +const sqliteTableHasColumn = async ( + client: Client, + table: string, + column: string, +): Promise => { + const rows = await queryRows<{ name: string }>( + client, + `PRAGMA table_info('${table.replaceAll("'", "''")}')`, + ); + return rows.some((row) => row.name === column); +}; -export const drizzleMigrationsTableExists = (sqlite: Database): boolean => { - const row = sqlite - .query<{ name: string }, [string]>( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - ) - .get("__drizzle_migrations"); +export const drizzleMigrationsTableExists = async (client: Client): Promise => { + const row = await queryFirst( + client, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ["__drizzle_migrations"], + ); return row != null; }; -export const readAppliedDrizzleMigrationHashes = (sqlite: Database): ReadonlyArray => { - if (!drizzleMigrationsTableExists(sqlite)) return []; +export const readAppliedDrizzleMigrationHashes = async ( + client: Client, +): Promise> => { + if (!(await drizzleMigrationsTableExists(client))) return []; - return sqlite - .query<{ hash: string }, []>("SELECT hash FROM __drizzle_migrations ORDER BY id ASC") - .all() - .map((row) => row.hash); + return ( + await queryRows<{ hash: string }>( + client, + "SELECT hash FROM __drizzle_migrations ORDER BY id ASC", + ) + ).map((row) => row.hash); }; const DrizzleJournal = Schema.Struct({ @@ -233,36 +244,36 @@ export const readBundledDrizzleMigrationHashes = ( }); }; -const hasBundledDrizzleMigrationPrefix = (input: { - readonly sqlite: Database; +const hasBundledDrizzleMigrationPrefix = async (input: { + readonly client: Client; readonly migrationsFolder: string; -}): boolean => { - if (!drizzleMigrationsTableExists(input.sqlite)) return true; +}): Promise => { + if (!(await drizzleMigrationsTableExists(input.client))) return true; - const applied = readAppliedDrizzleMigrationHashes(input.sqlite); + const applied = await readAppliedDrizzleMigrationHashes(input.client); const bundled = readBundledDrizzleMigrationHashes(input.migrationsFolder); return ( applied.length <= bundled.length && applied.every((hash, index) => hash === bundled[index]) ); }; -const isFumaSqliteDatabase = (path: string): boolean => { +const isFumaSqliteDatabase = async (path: string): Promise => { if (!fs.existsSync(path)) return false; - let db: Database | null = null; + let client: Client | null = null; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: native SQLite probe treats unreadable legacy files as non-FumaDB databases try { - db = new Database(path, { readonly: true }); - const settings = db - .query<{ name: string }, [string]>( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - ) - .get(`private_${localNamespace}_settings`); - return settings !== null || sqliteTableHasColumn(db, "source", "row_id"); + client = openLegacyLibsql(path); + const settings = await queryFirst( + client, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + [`private_${localNamespace}_settings`], + ); + return settings != null || (await sqliteTableHasColumn(client, "source", "row_id")); } catch { return false; } finally { - db?.close(); + client?.close(); } }; @@ -293,24 +304,23 @@ const moveSqliteFileSetToBackup = (path: string): string => { return backupPath; }; -const checkpointSqliteForFileMove = (input: { - readonly sqlite: Database; +const checkpointSqliteForFileMove = async (input: { + readonly client: Client; readonly path: string; -}) => { - const checkpoint = input.sqlite - .query<{ busy: number; log: number; checkpointed: number }, []>( - "PRAGMA wal_checkpoint(TRUNCATE)", - ) - .get(); +}): Promise => { + const checkpoint = await queryFirst<{ busy: number; log: number; checkpointed: number }>( + input.client, + "PRAGMA wal_checkpoint(TRUNCATE)", + ); if (checkpoint && checkpoint.busy !== 0) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: SQLite file replacement is synchronous; callers wrap this native failure into LocalExecutorCreateError + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: callers wrap this checkpoint-busy failure into LocalExecutorCreateError before a file move throw new LocalSqliteCheckpointError({ path: input.path, busy: checkpoint.busy }); } // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: DELETE mode is best-effort after a successful checkpoint; an open read handle can reject the mode switch without making the file set unsafe to move try { - input.sqlite.exec("PRAGMA journal_mode = DELETE"); + await input.client.execute("PRAGMA journal_mode = DELETE"); } catch (cause) { console.warn( `[executor] Checkpointed SQLite WAL for ${input.path}, but could not switch journal mode to DELETE before import. Continuing with the checkpointed file set.`, @@ -417,16 +427,19 @@ interface PreparedLegacySqlite { readonly preScopeBackup?: string; } -const prepareLegacySqliteForFumaImport = (input: { +const prepareLegacySqliteForFumaImport = async (input: { readonly storage: ResolvedStorage; readonly scopeId: string; -}): PreparedLegacySqlite => { - if (!fs.existsSync(input.storage.sqlitePath) || isFumaSqliteDatabase(input.storage.sqlitePath)) { +}): Promise => { + if ( + !fs.existsSync(input.storage.sqlitePath) || + (await isFumaSqliteDatabase(input.storage.sqlitePath)) + ) { return { legacySecrets: [] }; } - const legacySecrets = readLegacySecrets(input.storage.sqlitePath); - const preScopeBackup = moveAsidePreScopeDb(input.storage.sqlitePath); + const legacySecrets = await readLegacySecrets(input.storage.sqlitePath); + const preScopeBackup = await moveAsidePreScopeDb(input.storage.sqlitePath); if (preScopeBackup) { console.warn( `[executor] Pre-scope database detected; moved to ${preScopeBackup}. ` + @@ -438,15 +451,15 @@ const prepareLegacySqliteForFumaImport = (input: { return { legacySecrets, preScopeBackup }; } - const sqlite = new Database(input.storage.sqlitePath); + const client = openLegacyLibsql(input.storage.sqlitePath); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: legacy migration preflight must close SQLite before the FumaDB import re-opens the file try { - if (hasBundledDrizzleMigrationPrefix({ sqlite, migrationsFolder: MIGRATIONS_FOLDER })) { - sqlite.exec("PRAGMA journal_mode = WAL"); - migrate(drizzle(sqlite, { schema: legacyExecutorSchema }), { + if (await hasBundledDrizzleMigrationPrefix({ client, migrationsFolder: MIGRATIONS_FOLDER })) { + await client.execute("PRAGMA journal_mode = WAL"); + await migrate(drizzle({ client, schema: legacyExecutorSchema }), { migrationsFolder: MIGRATIONS_FOLDER, }); - importLegacySecrets(sqlite, input.scopeId, legacySecrets); + await importLegacySecrets(client, input.scopeId, legacySecrets); } else { console.warn( `[executor] Local SQLite migration history in ${input.storage.dataDir} ` + @@ -454,10 +467,10 @@ const prepareLegacySqliteForFumaImport = (input: { `Skipping legacy Drizzle replay and importing the existing schema as-is.`, ); } - checkpointSqliteForFileMove({ sqlite, path: input.storage.sqlitePath }); + await checkpointSqliteForFileMove({ client, path: input.storage.sqlitePath }); return { legacySecrets: [] }; } finally { - sqlite.close(); + client.close(); } }; @@ -487,7 +500,7 @@ const importMissingMarkedTables = async (input: { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: late plugin-table imports must close the active SQLite handle on failure try { const pickedTables = pickFumaTables(input.tables, missingTableSet); - const legacyScopeIds = readLegacySqliteScopeIds({ + const legacyScopeIds = await readLegacySqliteScopeIds({ sqlitePath: input.marker.backupPath, tables: pickedTables, scopeId: input.scopeId, @@ -500,7 +513,7 @@ const importMissingMarkedTables = async (input: { tables: pickedTables, scopeId: input.scopeId, }); - checkpointSqliteForFileMove({ sqlite: target.sqlite, path: input.storage.sqlitePath }); + await checkpointSqliteForFileMove({ client: target.client, path: input.storage.sqlitePath }); await target.close(); removeSqliteSidecars(input.storage.sqlitePath); @@ -544,14 +557,14 @@ export const importLegacySqliteIfNeeded = async (options: { } if (!fs.existsSync(storage.importMarkerPath) && fs.existsSync(storage.sqlitePath)) { - if (isFumaSqliteDatabase(storage.sqlitePath)) { + if (await isFumaSqliteDatabase(storage.sqlitePath)) { writeSqliteImportMarker(storage.importMarkerPath, { importedRows: 0, importedTables: [], recovered: true, }); } else { - const prepared = prepareLegacySqliteForFumaImport({ storage, scopeId }); + const prepared = await prepareLegacySqliteForFumaImport({ storage, scopeId }); if (prepared.preScopeBackup) { if (prepared.legacySecrets.length > 0) { const target = await createSqliteFumaDb({ @@ -564,7 +577,7 @@ export const importLegacySqliteIfNeeded = async (options: { await withQueryContext(target.db, { allowedScopeIds: new Set([scopeId]), }).createMany("secret", createLegacySecretRows(scopeId, prepared.legacySecrets)); - checkpointSqliteForFileMove({ sqlite: target.sqlite, path: storage.sqlitePath }); + await checkpointSqliteForFileMove({ client: target.client, path: storage.sqlitePath }); } finally { await target.close(); removeSqliteSidecars(storage.sqlitePath); @@ -589,7 +602,7 @@ export const importLegacySqliteIfNeeded = async (options: { !fs.existsSync(storage.importMarkerPath) && !fs.existsSync(storage.sqlitePath) && fs.existsSync(targetPath) && - isFumaSqliteDatabase(targetPath) + (await isFumaSqliteDatabase(targetPath)) ) { moveSqliteFileSet(targetPath, storage.sqlitePath); writeSqliteImportMarker(storage.importMarkerPath, { @@ -602,7 +615,7 @@ export const importLegacySqliteIfNeeded = async (options: { if ( !fs.existsSync(storage.sqlitePath) || fs.existsSync(storage.importMarkerPath) || - isFumaSqliteDatabase(storage.sqlitePath) + (await isFumaSqliteDatabase(storage.sqlitePath)) ) { return { imported: false, importedRows: 0, importedTables: [] }; } @@ -617,7 +630,7 @@ export const importLegacySqliteIfNeeded = async (options: { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local SQLite cutover must close and remove the temporary target database on import failure try { - const legacyScopeIds = readLegacySqliteScopeIds({ + const legacyScopeIds = await readLegacySqliteScopeIds({ sqlitePath: storage.sqlitePath, tables, scopeId, @@ -630,7 +643,7 @@ export const importLegacySqliteIfNeeded = async (options: { tables, scopeId, }); - checkpointSqliteForFileMove({ sqlite: target.sqlite, path: targetPath }); + await checkpointSqliteForFileMove({ client: target.client, path: targetPath }); await target.close(); removeSqliteSidecars(targetPath); @@ -664,7 +677,7 @@ const createLocalExecutorLayer = () => { Effect.gen(function* () { const { cwd, plugins } = yield* loadLocalPlugins; const scopeId = makeScopeId(cwd); - const tables = collectTables(plugins); + const tables = collectTables(); const importResult = yield* Effect.tryPromise({ try: () => @@ -689,7 +702,9 @@ const createLocalExecutorLayer = () => { (db) => Effect.promise(() => db.close()).pipe(Effect.ignore), ); - const migratedGoogleDiscoverySources = oneShotMigrateGoogleDiscoveryToOpenApi(sqlite.sqlite); + const migratedGoogleDiscoverySources = yield* Effect.promise(() => + oneShotMigrateGoogleDiscoveryToOpenApi(sqlite.client), + ); if (importResult.imported) { console.warn( diff --git a/apps/local/src/identity.ts b/apps/local/src/identity.ts new file mode 100644 index 000000000..6944d23d0 --- /dev/null +++ b/apps/local/src/identity.ts @@ -0,0 +1,50 @@ +import { Effect, Layer } from "effect"; + +import { IdentityProvider, type Principal } from "@executor-js/api/server"; + +// --------------------------------------------------------------------------- +// The local identity seam — the production implementation of the shared +// `IdentityProvider` from `@executor-js/api/server` for the single-user local +// daemon. +// +// Local is single-user: there is no account/org directory, and the executor it +// serves is a single boot-built instance scoped to the working directory (see +// `FixedExecutionProvider` in `app.ts`). So this provider ALWAYS resolves the +// one local Principal — there is no credential lookup to perform here. (The +// optional process-level Basic/Bearer gate that protects a network bind lives in +// the Bun serve shell, `serve.ts`; it is a coarse network gate, not request +// identity, and stays separate.) +// +// This is a genuine implementation, not a placeholder: `authenticate` returns a +// concrete, stable `Principal` whose `AuthContext` the executor API handlers +// read. The fixed executor ignores the `accountId`/`organizationId` (it does NOT +// rebuild a per-(user, org) scope the way cloud/self-host do), so these values +// only populate `AuthContext` for handlers/telemetry that surface "who am I". +// --------------------------------------------------------------------------- + +/** + * The single local Principal every request resolves to. Stable across the + * process; the `local` ids identify the single-user daemon in `AuthContext` and + * any "me"-style surfaces. The fixed executor's scope is cwd-derived (in + * `app.ts`), independent of these ids. + */ +export const LOCAL_PRINCIPAL: Principal = { + accountId: "local", + organizationId: "local", + organizationName: "Local", + email: "", + name: null, + avatarUrl: null, + roles: [], +}; + +/** + * The local `IdentityProvider`: always resolves `LOCAL_PRINCIPAL`. A complete + * `Layer` with no residual requirement (`RIdentity = never`), + * so the facade captures it once at boot like self-host's. + */ +export const localIdentityLayer: Layer.Layer = Layer.succeed(IdentityProvider)( + IdentityProvider.of({ + authenticate: () => Effect.succeed(LOCAL_PRINCIPAL), + }), +); diff --git a/apps/local/src/index.ts b/apps/local/src/index.ts index 14aca791e..1f759296f 100644 --- a/apps/local/src/index.ts +++ b/apps/local/src/index.ts @@ -3,7 +3,7 @@ export { getServerHandlers, disposeServerHandlers, type ServerHandlers, -} from "./server/main"; +} from "./main"; export { createExecutorHandle, disposeExecutor, @@ -11,6 +11,6 @@ export { reloadExecutor, type ExecutorHandle, type LocalExecutor, -} from "./server/executor"; -export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./server/mcp"; +} from "./executor"; +export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp"; export { startServer, type StartServerOptions, type ServerInstance } from "./serve"; diff --git a/apps/local/src/server/installation.ts b/apps/local/src/installation.ts similarity index 95% rename from apps/local/src/server/installation.ts rename to apps/local/src/installation.ts index cd30c879d..90f7ea50c 100644 --- a/apps/local/src/server/installation.ts +++ b/apps/local/src/installation.ts @@ -4,7 +4,7 @@ import { type SurfaceClient, } from "@executor-js/integrations-registry"; -const pkg = await import("../../package.json"); +const pkg = await import("../package.json"); const LOCAL_VERSION: string = pkg.version; // A `-` in semver indicates a prerelease (beta train). diff --git a/apps/local/src/server/integrations.ts b/apps/local/src/integrations.ts similarity index 100% rename from apps/local/src/server/integrations.ts rename to apps/local/src/integrations.ts diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts new file mode 100644 index 000000000..bce001a3a --- /dev/null +++ b/apps/local/src/main.ts @@ -0,0 +1,97 @@ +import { Context, Effect, Layer, ManagedRuntime } from "effect"; + +import { createExecutionEngine } from "@executor-js/execution"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; +import { makeLocalApiHandler } from "./app"; +import { getExecutorBundle } from "./executor"; +import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; + +// --------------------------------------------------------------------------- +// Local server handlers. +// +// The typed plugin `/api` is assembled by `ExecutorApp.make` (see `./app.ts`): +// the same shared facade cloud and self-host use, slotting local's single-user +// identity + the ONE boot executor (the `fixedExecution` seam) + console error +// capture + Swagger. The plugin set is the union of `executor.config.ts` +// (static, typed) and `executor.jsonc#plugins` (dynamic, jiti-loaded), resolved +// inside the boot bundle, so the composition happens after the bundle resolves +// rather than at module-eval time. +// +// The in-process `/mcp` surface stays local-platform: a single-engine handler +// over the SAME boot executor with a browser-approval store + stdio transport +// (not the shared multi-user `McpServingRoutes` envelope), built here and routed +// by the Bun shell in `serve.ts`. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Server handlers +// --------------------------------------------------------------------------- + +export type ServerHandlers = { + readonly api: { + readonly handler: (request: Request) => Promise; + readonly dispose: () => Promise; + }; + readonly mcp: McpRequestHandler; +}; + +const closeServerHandlers = async (handlers: ServerHandlers): Promise => { + await Effect.runPromise( + Effect.all( + [ + Effect.tryPromise({ + try: () => handlers.api.dispose(), + catch: (cause) => cause, + }).pipe(Effect.ignore), + Effect.tryPromise({ + try: () => handlers.mcp.close(), + catch: (cause) => cause, + }).pipe(Effect.ignore), + ], + { concurrency: "unbounded" }, + ), + ); +}; + +export const createServerHandlers = async (): Promise => { + // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). + const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(); + + // The in-process MCP server runs over the SAME boot executor, with its own + // engine instance (the browser-approval + stdio surface is local-only and not + // part of the shared API). Reuse the shared boot bundle so the MCP executor is + // byte-identical to the one the API serves. + const { executor } = await getExecutorBundle(); + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor(), + }); + const mcp = createMcpRequestHandler({ engine }); + + return { api: apiHandler, mcp }; +}; + +export class ServerHandlersService extends Context.Service()( + "@executor-js/local/ServerHandlersService", +) {} + +const ServerHandlersLive = Layer.effect(ServerHandlersService)( + Effect.acquireRelease( + Effect.promise(() => createServerHandlers()), + (handlers) => Effect.promise(() => closeServerHandlers(handlers)), + ), +); + +const serverHandlersRuntime = ManagedRuntime.make(ServerHandlersLive); + +export const getServerHandlers = (): Promise => + serverHandlersRuntime.runPromise(ServerHandlersService.asEffect()); + +export const disposeServerHandlers = async (): Promise => { + await Effect.runPromise( + Effect.tryPromise({ + try: () => serverHandlersRuntime.dispose(), + catch: (cause) => cause, + }).pipe(Effect.ignore), + ); +}; diff --git a/apps/local/src/server/mcp-browser-resume.test.ts b/apps/local/src/mcp-browser-resume.test.ts similarity index 98% rename from apps/local/src/server/mcp-browser-resume.test.ts rename to apps/local/src/mcp-browser-resume.test.ts index ccf8a1f89..cd79d3a79 100644 --- a/apps/local/src/server/mcp-browser-resume.test.ts +++ b/apps/local/src/mcp-browser-resume.test.ts @@ -22,18 +22,18 @@ import { Effect, Schema } from "effect"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; +import { collectTables } from "@executor-js/api/server"; import { FormElicitation, Scope, ScopeId, - collectTables, createExecutor, definePlugin, type Executor, } from "@executor-js/sdk"; import { createMcpRequestHandler } from "./mcp"; -import { createSqliteFumaDb } from "./sqlite-fumadb"; +import { createSqliteFumaDb } from "./db/sqlite-fumadb"; const TEST_BASE_URL = "http://local.test"; @@ -79,7 +79,7 @@ const approvalPlugin = definePlugin(() => ({ const makeExecutor = async (tmpDir: string): Promise => { const plugins = [approvalPlugin()] as const; const sqlite = await createSqliteFumaDb({ - tables: collectTables(plugins), + tables: collectTables(), namespace: "executor_local_browser_resume_test", path: join(tmpDir, "data.db"), }); diff --git a/apps/local/src/server/mcp-oauth.test.ts b/apps/local/src/mcp-oauth.test.ts similarity index 96% rename from apps/local/src/server/mcp-oauth.test.ts rename to apps/local/src/mcp-oauth.test.ts index 802172597..67e02d410 100644 --- a/apps/local/src/server/mcp-oauth.test.ts +++ b/apps/local/src/mcp-oauth.test.ts @@ -29,17 +29,22 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { Effect, Layer } from "effect"; import { addGroup, observabilityMiddleware } from "@executor-js/api"; -import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; +import { + CoreHandlers, + ExecutionEngineService, + ExecutorService, + collectTables, +} from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; -import { Scope, ScopeId, collectTables, createExecutor } from "@executor-js/sdk"; +import { Scope, ScopeId, createExecutor } from "@executor-js/sdk"; import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { mcpPlugin } from "@executor-js/plugin-mcp"; import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; import { ErrorCaptureLive } from "./observability"; -import { createSqliteFumaDb } from "./sqlite-fumadb"; +import { createSqliteFumaDb } from "./db/sqlite-fumadb"; // Shape of the test API: core + mcp group, with InternalError surfaced at // the top level so `observabilityMiddleware` can land its typed-error @@ -69,7 +74,7 @@ const startHarness = async (tmpDir: string): Promise => { fileSecretsPlugin({ directory: tmpDir }), ] as const; const sqlite = await createSqliteFumaDb({ - tables: collectTables(plugins), + tables: collectTables(), namespace: "executor_local_test", path: join(tmpDir, "data.db"), }); diff --git a/apps/local/src/server/mcp.ts b/apps/local/src/mcp.ts similarity index 96% rename from apps/local/src/server/mcp.ts rename to apps/local/src/mcp.ts index b6c052cd8..21d2afe1d 100644 --- a/apps/local/src/server/mcp.ts +++ b/apps/local/src/mcp.ts @@ -3,7 +3,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "@executor-js/host-mcp"; +import { jsonRpcErrorBody } from "@executor-js/host-mcp"; +import { + createExecutorMcpServer, + type ExecutorMcpServerConfig, +} from "@executor-js/host-mcp/tool-server"; import type { ResumeResponse } from "@executor-js/execution"; import { startIntegrationsRefresh } from "./integrations"; @@ -18,11 +22,11 @@ export type McpRequestHandler = { readonly close: () => Promise; }; +// Local serves these error bodies in-process; like the self-host store they are +// INNER responses (no CORS) — byte-identical to the prior hand-rolled copy +// (`content-type: application/json` only) via the canonical renderer. const jsonError = (status: number, code: number, message: string): Response => - new Response(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }), { - status, - headers: { "content-type": "application/json" }, - }); + jsonRpcErrorBody(status, code, message, { cors: false }); const formatBoundaryError = (error: unknown): unknown => { // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: MCP request handler catches unknown SDK/runtime failures for process logging diff --git a/apps/local/src/observability.ts b/apps/local/src/observability.ts new file mode 100644 index 000000000..6c13257ae --- /dev/null +++ b/apps/local/src/observability.ts @@ -0,0 +1,10 @@ +// --------------------------------------------------------------------------- +// Local-app `ErrorCapture` — the shared console implementation with a `local-` +// trace id prefix. Prints the squashed cause + pretty-printed structured cause +// to stderr and returns a short correlation id. Operators can grep for the id +// in their terminal scrollback when a user reports an opaque 500 traceId. +// --------------------------------------------------------------------------- + +import { consoleErrorCapture } from "@executor-js/api/server"; + +export const ErrorCaptureLive = consoleErrorCapture("local"); diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index 28c576e81..cc6ef3b9c 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -12,8 +12,8 @@ import { readdirSync } from "node:fs"; import type { Subprocess } from "bun"; import { setOAuthCompletionListener } from "@executor-js/api"; import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; -import { startIntegrationsRefresh } from "./server/integrations"; -import { getServerHandlers } from "./server/main"; +import { startIntegrationsRefresh } from "./integrations"; +import { getServerHandlers } from "./main"; import { DEFAULT_ALLOWED_HOSTS, hasFileExtension, diff --git a/apps/local/src/server/main.ts b/apps/local/src/server/main.ts deleted file mode 100644 index 6644c4785..000000000 --- a/apps/local/src/server/main.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { HttpApiBuilder, HttpApiSwagger } from "effect/unstable/httpapi"; -import { HttpRouter, HttpServer } from "effect/unstable/http"; -import { Context, Effect, Layer, ManagedRuntime } from "effect"; - -import { observabilityMiddleware } from "@executor-js/api"; -import { - CoreHandlers, - ExecutorService, - ExecutionEngineService, - composePluginApi, - composePluginHandlers, -} from "@executor-js/api/server"; -import { createExecutionEngine } from "@executor-js/execution"; -import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; -import { getExecutorBundle } from "./executor"; -import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; -import { ErrorCaptureLive } from "./observability"; - -// --------------------------------------------------------------------------- -// Local server API. -// -// Every plugin contributes its `HttpApiGroup` and handler `Layer` through -// the spec (`routes()` / `handlers(self)` on `PluginSpec`); the host folds -// the group list into a single `HttpApi` and merges the handler layers -// into the runtime. The plugin set is the union of `executor.config.ts` -// (static, typed) and `executor.jsonc#plugins` (dynamic, jiti-loaded), -// so `LocalApi` can't be constructed until the executor bundle resolves -// — composition happens inside `createServerHandlers` instead of at -// module-eval time. -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Server handlers -// --------------------------------------------------------------------------- - -export type ServerHandlers = { - readonly api: { - readonly handler: (request: Request) => Promise; - readonly dispose: () => Promise; - }; - readonly mcp: McpRequestHandler; -}; - -const closeServerHandlers = async (handlers: ServerHandlers): Promise => { - await Effect.runPromise( - Effect.all( - [ - Effect.tryPromise({ - try: () => handlers.api.dispose(), - catch: (cause) => cause, - }).pipe(Effect.ignore), - Effect.tryPromise({ - try: () => handlers.mcp.close(), - catch: (cause) => cause, - }).pipe(Effect.ignore), - ], - { concurrency: "unbounded" }, - ), - ); -}; - -export const createServerHandlers = async (): Promise => { - const { executor, plugins } = await getExecutorBundle(); - const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); - - const LocalApi = composePluginApi(plugins); - // `ErrorCaptureLive` logs causes to the console and returns a short - // correlation id. Provided above the handler + middleware layers so - // both the `withCapture` typed-channel translation AND the - // `observabilityMiddleware` defect catchall see the same - // implementation. - const LocalObservability = observabilityMiddleware(LocalApi); - const LocalApiBase = HttpApiBuilder.layer(LocalApi).pipe( - Layer.provide(CoreHandlers), - Layer.provide(LocalObservability), - Layer.provide(ErrorCaptureLive), - ); - - // Spec-based plugin handlers — each plugin's `handlers(self)` Layer is - // built against its own bundled HttpApi for full type safety inside the - // plugin, and merges into the runtime `LocalApi` by group identity. - // Each plugin's handler bodies that yield its `*ExtensionService` are - // satisfied because `composePluginHandlers` provides `executor[id]` to - // the plugin's own `Layer.succeed(*ExtensionService)(self)` wiring. - const SpecPluginHandlers = composePluginHandlers(plugins, executor); - - const localApiLayer = LocalApiBase.pipe( - Layer.provideMerge(HttpApiSwagger.layer(LocalApi, { path: "/docs" })), - Layer.provideMerge(SpecPluginHandlers), - Layer.provideMerge(Layer.succeed(ExecutorService)(executor)), - Layer.provideMerge(Layer.succeed(ExecutionEngineService)(engine)), - Layer.provideMerge(HttpServer.layerServices), - Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), - ); - const api = HttpRouter.toWebHandler(localApiLayer); - const apiHandler: ServerHandlers["api"] = { - handler: (request) => api.handler(request), - dispose: api.dispose, - }; - - const mcp = createMcpRequestHandler({ engine }); - - return { api: apiHandler, mcp }; -}; - -export class ServerHandlersService extends Context.Service()( - "@executor-js/local/ServerHandlersService", -) {} - -const ServerHandlersLive = Layer.effect(ServerHandlersService)( - Effect.acquireRelease( - Effect.promise(() => createServerHandlers()), - (handlers) => Effect.promise(() => closeServerHandlers(handlers)), - ), -); - -const serverHandlersRuntime = ManagedRuntime.make(ServerHandlersLive); - -export const getServerHandlers = (): Promise => - serverHandlersRuntime.runPromise(ServerHandlersService.asEffect()); - -export const disposeServerHandlers = async (): Promise => { - await Effect.runPromise( - Effect.tryPromise({ - try: () => serverHandlersRuntime.dispose(), - catch: (cause) => cause, - }).pipe(Effect.ignore), - ); -}; diff --git a/apps/local/src/server/migrate-google-discovery-bindings.test.ts b/apps/local/src/server/migrate-google-discovery-bindings.test.ts deleted file mode 100644 index 06fbcf501..000000000 --- a/apps/local/src/server/migrate-google-discovery-bindings.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -// End-to-end test for the google-discovery portion of -// `0007_normalize_plugin_secret_refs.sql`. Seeds a -// google_discovery_source row with the legacy json shape (config -// containing auth/credentials), runs the migration, asserts the new -// columns and child tables are populated. - -import { afterEach, describe, expect, it } from "@effect/vitest"; -import { Database } from "bun:sqlite"; -import { Schema } from "effect"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; - -import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pre-0007-schema"; - -const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); - -const migratedConfig = Schema.Struct({ - auth: Schema.optional(Schema.Unknown), - service: Schema.String, -}); -const decodeMigratedConfig = Schema.decodeUnknownSync(Schema.fromJsonString(migratedConfig)); - -const tempDirs = new Set(); - -const createTempDbPath = () => { - const dir = mkdtempSync(join(tmpdir(), "gd-mig-")); - tempDirs.add(dir); - return join(dir, "test.sqlite"); -}; - -describe("0007_normalize_plugin_secret_refs (google-discovery)", () => { - afterEach(() => { - for (const dir of tempDirs) { - rmSync(dir, { recursive: true, force: true }); - } - tempDirs.clear(); - }); - - it("flattens oauth2 auth into columns", () => { - const dbPath = createTempDbPath(); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); - - db.prepare( - "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "drive", - "Drive", - JSON.stringify({ - name: "Drive", - discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", - service: "drive", - version: "v3", - rootUrl: "https://www.googleapis.com/", - servicePath: "drive/v3/", - auth: { - kind: "oauth2", - connectionId: "conn-1", - clientIdSecretId: "client-id", - clientSecretSecretId: "client-secret", - scopes: ["https://www.googleapis.com/auth/drive"], - }, - }), - Date.now(), - Date.now(), - ); - - db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); - - const after = new Database(dbPath, { readonly: true }); - const row = after - .prepare( - "SELECT auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes, config FROM google_discovery_source WHERE id = ?", - ) - .get("drive") as Record; - expect(row.auth_kind).toBe("oauth2"); - expect(row.auth_connection_id).toBe("conn-1"); - expect(row.auth_client_id_secret_id).toBe("client-id"); - expect(row.auth_client_secret_secret_id).toBe("client-secret"); - // auth_scopes column is text-typed (string[] gets stored as JSON in sqlite). - expect(row.auth_scopes).toContain("drive"); - // The auth key should be stripped from config json. - const config = decodeMigratedConfig(row.config); - expect(config.auth).toBeUndefined(); - expect(config.service).toBe("drive"); - after.close(); - }); - - it("explodes credentials.headers and queryParams into child rows", () => { - const dbPath = createTempDbPath(); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); - - db.prepare( - "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "with-creds", - "With Creds", - JSON.stringify({ - name: "With Creds", - discoveryUrl: "https://example.com/discovery", - service: "svc", - version: "v1", - rootUrl: "https://example.com/", - servicePath: "svc/v1/", - auth: { kind: "none" }, - credentials: { - headers: { - "X-Static": "literal", - Authorization: { secretId: "tok-secret", prefix: "Bearer " }, - }, - queryParams: { - api_key: { secretId: "key-secret" }, - }, - }, - }), - Date.now(), - Date.now(), - ); - - db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); - - const after = new Database(dbPath, { readonly: true }); - const headers = after - .prepare( - "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_header WHERE source_id = ? ORDER BY name", - ) - .all("with-creds") as ReadonlyArray>; - expect(headers).toHaveLength(2); - const byName = new Map(headers.map((h) => [h.name!, h])); - expect(byName.get("X-Static")).toMatchObject({ - kind: "text", - text_value: "literal", - }); - expect(byName.get("Authorization")).toMatchObject({ - kind: "secret", - secret_id: "tok-secret", - secret_prefix: "Bearer ", - }); - - const params = after - .prepare( - "SELECT name, secret_id FROM google_discovery_source_credential_query_param WHERE source_id = ?", - ) - .all("with-creds") as ReadonlyArray>; - expect(params).toHaveLength(1); - expect(params[0]).toMatchObject({ name: "api_key", secret_id: "key-secret" }); - - after.close(); - }); - - it("survives auth.kind=none with no credentials", () => { - const dbPath = createTempDbPath(); - const db = new Database(dbPath); - db.exec(PRE_0007_SQL); - stampPriorMigrationsApplied(db); - - db.prepare( - "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ).run( - "default-scope", - "bare", - "Bare", - JSON.stringify({ - name: "Bare", - discoveryUrl: "https://example.com/discovery", - service: "svc", - version: "v1", - rootUrl: "https://example.com/", - servicePath: "svc/v1/", - auth: { kind: "none" }, - }), - Date.now(), - Date.now(), - ); - - db.close(); - const drizzleDb = drizzle(new Database(dbPath)); - migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); - - const after = new Database(dbPath, { readonly: true }); - const row = after - .prepare( - "SELECT auth_kind, auth_connection_id, auth_scopes FROM google_discovery_source WHERE id = ?", - ) - .get("bare") as Record; - expect(row.auth_kind).toBe("none"); - expect(row.auth_connection_id).toBeNull(); - - const headerCount = ( - after - .prepare( - "SELECT count(*) as n FROM google_discovery_source_credential_header WHERE source_id = ?", - ) - .get("bare") as { n: number } - ).n; - expect(headerCount).toBe(0); - after.close(); - }); -}); diff --git a/apps/local/src/server/observability.ts b/apps/local/src/server/observability.ts deleted file mode 100644 index 0b85721e0..000000000 --- a/apps/local/src/server/observability.ts +++ /dev/null @@ -1,33 +0,0 @@ -// --------------------------------------------------------------------------- -// Local-app `ErrorCapture` — console implementation. -// -// Unlike the cloud app (Sentry-backed), the CLI just prints the squashed -// cause + pretty-printed structured cause to stderr and returns a short -// correlation id. Operators can grep for the id in their terminal -// scrollback when a user reports an opaque 500 traceId. -// --------------------------------------------------------------------------- - -import { Cause, Effect, Layer } from "effect"; - -import { ErrorCapture } from "@executor-js/api"; - -const nextTraceId = () => - `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - -export const ErrorCaptureLive: Layer.Layer = Layer.succeed( - ErrorCapture, - ErrorCapture.of({ - captureException: (cause) => - Effect.sync(() => { - const traceId = nextTraceId(); - const squashed = Cause.squash(cause); - console.error( - `[executor ${traceId}]`, - // oxlint-disable-next-line executor/no-instanceof-error -- boundary: console logger preserves native Error stack output - squashed instanceof Error ? (squashed.stack ?? squashed) : squashed, - ); - console.error(`[executor ${traceId}] cause:`, Cause.pretty(cause)); - return traceId; - }), - }), -); diff --git a/apps/local/src/server/sqlite-fumadb.ts b/apps/local/src/server/sqlite-fumadb.ts deleted file mode 100644 index e0bb91a37..000000000 --- a/apps/local/src/server/sqlite-fumadb.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Database } from "bun:sqlite"; -import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; -import { fumadb, type FumaDB } from "fumadb"; -import { - createDrizzleRuntimeSchemaFromTables, - createDrizzleRuntimeSchemaSqlFromTables, - drizzleAdapter, -} from "fumadb/adapters/drizzle"; -import { schema as fumaSchema, type RelationsMap } from "fumadb/schema"; - -import type { FumaDb, FumaTables } from "@executor-js/sdk"; - -type SqliteFumaSchema = ReturnType< - typeof fumaSchema> ->; - -export interface SqliteFumaDb { - readonly db: FumaDb>; - readonly fuma: FumaDB[]>; - readonly drizzle: BunSQLiteDatabase>; - readonly sqlite: Database; - readonly close: () => Promise; -} - -export interface CreateSqliteFumaDbOptions { - readonly tables: TTables; - readonly namespace: string; - readonly version?: string; - readonly path: string; -} - -export const createSqliteFumaDb = async ( - options: CreateSqliteFumaDbOptions, -): Promise> => { - const version = options.version ?? "1.0.0"; - const sqlite = new Database(options.path, { create: true }); - sqlite.exec("PRAGMA foreign_keys = ON"); - sqlite.exec("PRAGMA journal_mode = WAL"); - - const schema = createDrizzleRuntimeSchemaFromTables({ - tables: options.tables, - namespace: options.namespace, - version, - provider: "sqlite", - }); - const drizzleDb = drizzle(sqlite, { schema }); - - for (const statement of createDrizzleRuntimeSchemaSqlFromTables({ - tables: options.tables, - namespace: options.namespace, - version, - provider: "sqlite", - })) { - sqlite.exec(statement); - } - const connectionColumns = sqlite - .prepare("PRAGMA table_info('connection')") - .all() as ReadonlyArray<{ readonly name: string }>; - if ( - connectionColumns.length > 0 && - !connectionColumns.some((column) => column.name === "identity_override") - ) { - sqlite.exec("ALTER TABLE connection ADD COLUMN identity_override TEXT"); - } - - const latestSchema = fumaSchema({ - version, - tables: options.tables, - }); - const factory = fumadb({ - namespace: options.namespace, - schemas: [latestSchema], - }); - const fuma = factory.client( - drizzleAdapter({ - db: drizzleDb, - provider: "sqlite", - }), - ); - - return { - db: fuma.orm(version), - fuma, - drizzle: drizzleDb, - sqlite, - close: async () => { - sqlite.close(); - }, - }; -}; diff --git a/apps/local/src/testing/libsql-test-db.ts b/apps/local/src/testing/libsql-test-db.ts new file mode 100644 index 000000000..45242f8e1 --- /dev/null +++ b/apps/local/src/testing/libsql-test-db.ts @@ -0,0 +1,100 @@ +import { createClient, type Client, type InArgs, type Row } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import { resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Async libSQL test helper for the local migration/import suites. These tests +// used to open a synchronous bun:sqlite `Database` and call +// `.exec(sql)` / `.prepare(sql).run(...args)` / `.get(...args)` / `.all(...args)`. +// libSQL is async, so this thin wrapper keeps the same call shape (just awaited) +// over a single libSQL connection to the same `file:` URL — letting the suites +// run under plain Node vitest with no bun:sqlite dependency. +// --------------------------------------------------------------------------- + +const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); + +export class LibsqlTestDb { + readonly client: Client; + + constructor(path: string = ":memory:") { + this.client = createClient({ url: toUrl(path) }); + } + + /** Run one or more `;`-separated statements (bun:sqlite `.exec`). */ + async exec(sql: string): Promise { + await this.client.executeMultiple(sql); + } + + /** Run a parameterized statement (bun:sqlite `.prepare(sql).run(...args)`). */ + async run(sql: string, ...args: unknown[]): Promise { + await this.client.execute({ sql, args: args as InArgs }); + } + + /** First row of a query (bun:sqlite `.prepare(sql).get(...args)`), or undefined. */ + async get(sql: string, ...args: unknown[]): Promise { + return (await this.client.execute({ sql, args: args as InArgs })).rows[0] as T | undefined; + } + + /** All rows of a query (bun:sqlite `.prepare(sql).all(...args)`). */ + async all(sql: string, ...args: unknown[]): Promise { + // oxlint-disable-next-line executor/no-double-cast -- boundary: test helper narrows libSQL's structural `Row[]` to the caller's row type (the SQL is the contract) + return (await this.client.execute({ sql, args: args as InArgs })).rows as unknown as T[]; + } + + /** + * Prepared-statement shape mirroring bun:sqlite's `.prepare(sql)` so existing + * suites keep their `.run(...) / .get(...) / .all(...)` chains (just awaited). + */ + prepare(sql: string): LibsqlPreparedStatement { + return new LibsqlPreparedStatement(this.client, sql); + } + + close(): void { + this.client.close(); + } +} + +export class LibsqlPreparedStatement { + constructor( + private readonly client: Client, + private readonly sql: string, + ) {} + + async run(...args: unknown[]): Promise { + await this.client.execute({ sql: this.sql, args: args as InArgs }); + } + + async get(...args: unknown[]): Promise { + return (await this.client.execute({ sql: this.sql, args: args as InArgs })).rows[0] as + | T + | undefined; + } + + async all(...args: unknown[]): Promise { + // oxlint-disable-next-line executor/no-double-cast -- boundary: test helper narrows libSQL's structural `Row[]` to the caller's row type (the SQL is the contract) + return (await this.client.execute({ sql: this.sql, args: args as InArgs })) + .rows as unknown as T[]; + } +} + +/** Open a fresh in-memory or file-backed libSQL test DB. */ +export const openTestDb = (path?: string): LibsqlTestDb => new LibsqlTestDb(path); + +/** Open a libSQL client for a file path (caller closes it). */ +export const openTestClient = (path: string): Client => createClient({ url: toUrl(path) }); + +/** + * Replays drizzle migrations against a file DB through the libSQL migrator + * (replaces `migrate(drizzle(new Database(path)), { migrationsFolder })`). Opens + * and closes its own connection. + */ +export const runMigrations = async (path: string, migrationsFolder: string): Promise => { + const client = createClient({ url: toUrl(path) }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test migrator must close its connection whether or not the migration throws + try { + await migrate(drizzle({ client }), { migrationsFolder }); + } finally { + client.close(); + } +}; diff --git a/apps/local/src/server/__test-helpers__/pre-0007-schema.ts b/apps/local/src/testing/pre-0007-schema.ts similarity index 93% rename from apps/local/src/server/__test-helpers__/pre-0007-schema.ts rename to apps/local/src/testing/pre-0007-schema.ts index ebe50ea3c..b4e44a3b3 100644 --- a/apps/local/src/server/__test-helpers__/pre-0007-schema.ts +++ b/apps/local/src/testing/pre-0007-schema.ts @@ -3,7 +3,7 @@ // after 0006_neat_terror), then runs drizzle's migrator which executes // only `0007_normalize_plugin_secret_refs.sql` thanks to the stamp. -import { Database } from "bun:sqlite"; +import { type LibsqlTestDb } from "./libsql-test-db"; export const PRE_0007_SQL = ` CREATE TABLE __drizzle_migrations ( @@ -131,8 +131,9 @@ export const PRE_0007_SQL = ` // folderMillis (from the journal) is <= that timestamp. export const STAMP_BEFORE = 1777850000001; -export const stampPriorMigrationsApplied = (db: Database) => { - db.prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)").run( +export const stampPriorMigrationsApplied = async (db: LibsqlTestDb): Promise => { + await db.run( + "INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)", "pre-0007-marker", STAMP_BEFORE, ); diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts index 3b42a7bd2..aab4f7d23 100644 --- a/apps/local/vite.config.ts +++ b/apps/local/vite.config.ts @@ -35,13 +35,13 @@ const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); * during development, so you don't need a separate server process. */ function executorApiPlugin(): Plugin { - let handlers: import("./src/server/main").ServerHandlers | null = null; + let handlers: import("./src/main").ServerHandlers | null = null; return { name: "executor-api", configureServer(server) { server.watcher.on("change", (path) => { - if (path.includes("/src/server/") || path.endsWith("/executor.config.ts")) { + if (path.includes("/apps/local/src/") || path.endsWith("/executor.config.ts")) { handlers = null; } }); @@ -55,7 +55,7 @@ function executorApiPlugin(): Plugin { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite middleware must convert handler failures into HTTP 500 responses try { if (!handlers) { - const { getServerHandlers } = await import("./src/server/main"); + const { getServerHandlers } = await import("./src/main"); handlers = await getServerHandlers(); } diff --git a/bun.lock b/bun.lock index a1dadb514..6207f90d3 100644 --- a/bun.lock +++ b/bun.lock @@ -59,6 +59,7 @@ "@effect/atom-react": "catalog:", "@effect/opentelemetry": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/cloudflare": "workspace:*", "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -104,6 +105,7 @@ "@electric-sql/pglite": "^0.4.4", "@electric-sql/pglite-socket": "^0.1.4", "@executor-js/cli": "workspace:*", + "@playwright/test": "^1.60.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@types/react": "catalog:", @@ -112,6 +114,7 @@ "concurrently": "^9.2.1", "drizzle-kit": "catalog:", "jiti": "^2.6.1", + "playwright": "^1.60.0", "typescript": "catalog:", "vite": "catalog:", "vitest": "^4.1.5", @@ -151,6 +154,93 @@ "vite": "catalog:", }, }, + "apps/host-cloudflare": { + "name": "@executor-js/host-cloudflare", + "dependencies": { + "@effect/atom-react": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/app": "workspace:*", + "@executor-js/cloudflare": "workspace:*", + "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-encrypted-secrets": "workspace:*", + "@executor-js/plugin-graphql": "workspace:*", + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/react": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@jitl/quickjs-wasmfile-release-sync": "catalog:", + "@modelcontextprotocol/sdk": "^1.29.0", + "@tanstack/react-router": "catalog:", + "drizzle-orm": "catalog:", + "effect": "catalog:", + "fumadb": "workspace:*", + "jose": "^5.9.6", + "quickjs-emscripten-core": "0.31.0", + "react": "catalog:", + "react-dom": "catalog:", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250410.0", + "@effect/vitest": "catalog:", + "@executor-js/vite-plugin": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@tanstack/router-plugin": "^1.167.12", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:", + "wrangler": "^4.95.0", + }, + }, + "apps/host-selfhost": { + "name": "@executor-js/host-selfhost", + "version": "0.0.0", + "dependencies": { + "@better-auth/api-key": "^1.6.11", + "@effect/atom-react": "catalog:", + "@effect/platform-bun": "catalog:", + "@executor-js/api": "workspace:*", + "@executor-js/app": "workspace:*", + "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-encrypted-secrets": "workspace:*", + "@executor-js/plugin-graphql": "workspace:*", + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/react": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@libsql/client": "catalog:", + "@libsql/kysely-libsql": "catalog:", + "@modelcontextprotocol/sdk": "^1.29.0", + "@tanstack/react-router": "catalog:", + "better-auth": "^1.6.11", + "drizzle-orm": "catalog:", + "effect": "catalog:", + "fumadb": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/vite-plugin": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@tanstack/router-plugin": "^1.167.12", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "bun-types": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:", + }, + }, "apps/local": { "name": "@executor-js/local", "version": "1.4.4", @@ -175,6 +265,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", + "@libsql/client": "catalog:", "@modelcontextprotocol/sdk": "^1.12.1", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", @@ -301,6 +392,7 @@ "version": "1.4.21", "dependencies": { "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", }, @@ -427,10 +519,9 @@ "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", - "@types/better-sqlite3": "^7.6.13", + "@libsql/client": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", - "better-sqlite3": "^12.9.0", "drizzle-orm": "catalog:", "react": "catalog:", "tsup": "catalog:", @@ -485,6 +576,26 @@ "vite": "catalog:", }, }, + "packages/hosts/cloudflare": { + "name": "@executor-js/cloudflare", + "version": "0.0.0", + "dependencies": { + "@executor-js/api": "workspace:*", + "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", + "agents": "^0.10.0", + "effect": "catalog:", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250620.0", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "bun-types": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/hosts/mcp": { "name": "@executor-js/host-mcp", "version": "1.4.4", @@ -603,6 +714,21 @@ "typescript": "catalog:", }, }, + "packages/plugins/encrypted-secrets": { + "name": "@executor-js/plugin-encrypted-secrets", + "version": "0.0.0", + "dependencies": { + "@executor-js/sdk": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "bun-types": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:", + }, + }, "packages/plugins/example": { "name": "@executor-js/plugin-example", "version": "1.4.33", @@ -876,6 +1002,7 @@ }, }, "patchedDependencies": { + "libsql@0.5.29": "patches/libsql@0.5.29.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", }, "catalog": { @@ -885,6 +1012,8 @@ "@effect/platform-node": "4.0.0-beta.59", "@effect/vitest": "4.0.0-beta.59", "@jitl/quickjs-wasmfile-release-sync": "0.31.0", + "@libsql/client": "^0.17.3", + "@libsql/kysely-libsql": "^0.4.1", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-router": "^1.168.10", "@tanstack/react-start": "^1.167.16", @@ -1055,6 +1184,26 @@ "@base-ui/utils": ["@base-ui/utils@0.2.7", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA=="], + "@better-auth/api-key": ["@better-auth/api-key@1.6.12", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.12", "@better-auth/utils": "0.4.1", "better-auth": "^1.6.12", "better-call": "1.3.5" } }, "sha512-LTM90m9vWvSwSCdlXKe250jU2OUww1WTBazEOHmafPj+NDNwXX21TwDxkUIY+FAhgRCw/81Tzuki4auZmeZctw=="], + + "@better-auth/core": ["@better-auth/core@1.6.12", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-6mXtYSYfo6TvHHCZAZmfjvIQQtBDWzWzwy9iIWPEoede2lP2SuJzkfIQNuTtIGzZcn7a9iuzIm1jWDBzfnBARg=="], + + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.12", "", { "peerDependencies": { "@better-auth/core": "^1.6.12", "@better-auth/utils": "0.4.1", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-g0sKQstvXHH70s+TjAXo86cNyWV60ahhJm1sow27RyW41U10vfBehOFinU3GPESyxl/fEr9D27rk3jdl6E3l3A=="], + + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.12", "", { "peerDependencies": { "@better-auth/core": "^1.6.12", "@better-auth/utils": "0.4.1", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-KhPwPmLj+MoTVGV6goPfCYf/7Fuiy2Q37GEWhvQdoFjkYKbGo995OoghBVNBnAYOakYvTYjG0JebCfiETBVX3g=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.12", "", { "peerDependencies": { "@better-auth/core": "^1.6.12", "@better-auth/utils": "0.4.1" } }, "sha512-flblsePBCcB0DA6hewAOupxyypNTQczZvkNYvRrsVlBDIh0+vHBU/dTjoDmuQnZ3egTdFNnMeC+VrNnqt/GFUg=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.12", "", { "peerDependencies": { "@better-auth/core": "^1.6.12", "@better-auth/utils": "0.4.1", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-IeiHZN9PtIyiqYgTDlrmm8sYI++5p1OI49uWB7LHg2+touiaNUGe0uWYymQpw1zq1e8FJxKlwvOc5vw6nGrI6g=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.12", "", { "peerDependencies": { "@better-auth/core": "^1.6.12", "@better-auth/utils": "0.4.1", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-+GvU8vZ3aJUHDBuR5PxtU5OpPQS2T9ND7s2JYm63bD6rnYztLwEo8bwHL3BvsTwSvCjFHZCtsn1A+6qyoOzTMw=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.12", "", { "peerDependencies": { "@better-auth/core": "^1.6.12", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21" } }, "sha512-g59qLPq9SROyku0X5tiZpXXiVrsbjB1QA6OctOt9svzj7NjCFBoCAO9QlBiOTUolo0l9CF6fLlc85PoBkY5RtA=="], + + "@better-auth/utils": ["@better-auth/utils@0.4.1", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="], @@ -1109,7 +1258,7 @@ "@clack/prompts": ["@clack/prompts@1.3.0", "", { "dependencies": { "@clack/core": "1.3.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw=="], - "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], @@ -1117,15 +1266,15 @@ "@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.15.0", "", { "dependencies": { "cjs-module-lexer": "^1.2.3", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "wrangler": "4.85.0", "zod": "^3.25.76" }, "peerDependencies": { "@vitest/runner": "^4.1.0", "@vitest/snapshot": "^4.1.0", "vitest": "^4.1.0" } }, "sha512-RldzOt2az3mxICTxT7GTSBpm6f61lx4LWSilRHm4pJlYAGmfGu1pyinqJw3UmPZS9N/mrN7XwdZAqFV6hhmWaQ=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260526.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/pR3GH3gfv0PUp7DjI8v0aAIDOqFwibq4bg5xT7TZgcVdBV/cJQWckdXCMqiRtHiawLwogUX00EIOINkYJ1Zqg=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260526.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rcyu0iANYfaiezKh3Mcao1O4IIgVfQldxduiL5TZT1sP0NIeRY4YReSTrzPxNnXxSYaIqaqRHMcHbUM/ic4knA=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260526.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5EZAEnlLwa9oGJRo8Nd3iY5Wcd9ROGNNG90xNIGp8MEjj8v2jTn42NC47fCZKFdnLj3+S+vWEhu1x0GVJnALjA=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260526.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-X/YBQXeXFeCN7QTStoWrATEBc9WKl7PIqkw/dQkjyJ72gh3rkLe0+Xkzp3wO7gtxTDQMa7NPGy1W4+sdMf8q1g=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260526.1", "", { "os": "win32", "cpu": "x64" }, "sha512-R+tqpFFdcfZIljx8fIW9rj9fRTtDgfoA2yonsfAGa6e8snrmr+38mdFHtkRC0D3UyZpn/hOtmXiUBfdX2gMR7Q=="], "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260415.1", "", {}, "sha512-9sEq9cZzr4s075U/TfjvdSmiX+u2NMOAIcFcCfd24FDtPfR7Iw3SbuQxkcgtpx/Bvg0au9PmQ0ZJfBaIitG0gw=="], @@ -1303,6 +1452,8 @@ "@executor-js/cloud": ["@executor-js/cloud@workspace:apps/cloud"], + "@executor-js/cloudflare": ["@executor-js/cloudflare@workspace:packages/hosts/cloudflare"], + "@executor-js/codemode-core": ["@executor-js/codemode-core@workspace:packages/kernel/core"], "@executor-js/config": ["@executor-js/config@workspace:packages/core/config"], @@ -1317,8 +1468,12 @@ "@executor-js/execution": ["@executor-js/execution@workspace:packages/core/execution"], + "@executor-js/host-cloudflare": ["@executor-js/host-cloudflare@workspace:apps/host-cloudflare"], + "@executor-js/host-mcp": ["@executor-js/host-mcp@workspace:packages/hosts/mcp"], + "@executor-js/host-selfhost": ["@executor-js/host-selfhost@workspace:apps/host-selfhost"], + "@executor-js/integrations-registry": ["@executor-js/integrations-registry@workspace:packages/core/integrations-registry"], "@executor-js/ir": ["@executor-js/ir@workspace:packages/kernel/ir"], @@ -1329,6 +1484,8 @@ "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], + "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"], + "@executor-js/plugin-example": ["@executor-js/plugin-example@workspace:packages/plugins/example"], "@executor-js/plugin-file-secrets": ["@executor-js/plugin-file-secrets@workspace:packages/plugins/file-secrets"], @@ -1507,6 +1664,36 @@ "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="], + + "@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="], + + "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A=="], + + "@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.29", "", { "os": "darwin", "cpu": "x64" }, "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ=="], + + "@libsql/hrana-client": ["@libsql/hrana-client@0.10.0", "", { "dependencies": { "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5" } }, "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw=="], + + "@libsql/isomorphic-fetch": ["@libsql/isomorphic-fetch@0.2.5", "", {}, "sha512-8s/B2TClEHms2yb+JGpsVRTPBfy1ih/Pq6h6gvyaNcYnMVJvgQRY7wAa8U2nD0dppbCuDU5evTNMEhrQ17ZKKg=="], + + "@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="], + + "@libsql/kysely-libsql": ["@libsql/kysely-libsql@0.4.1", "", { "dependencies": { "@libsql/client": "^0.8.0" }, "peerDependencies": { "kysely": "*" } }, "sha512-mCTa6OWgoME8LNu22COM6XjKBmcMAvNtIO6DYM10jSAFq779fVlrTKQEmXIB8TwJVU65dA5jGCpT8gkDdWS0HQ=="], + + "@libsql/linux-arm-gnueabihf": ["@libsql/linux-arm-gnueabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ=="], + + "@libsql/linux-arm-musleabihf": ["@libsql/linux-arm-musleabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg=="], + + "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w=="], + + "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg=="], + + "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg=="], + + "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w=="], + + "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.1", "", {}, "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA=="], "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], @@ -1577,6 +1764,10 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], + + "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1795,6 +1986,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -2575,6 +2768,10 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="], + "better-auth": ["better-auth@1.6.12", "", { "dependencies": { "@better-auth/core": "1.6.12", "@better-auth/drizzle-adapter": "1.6.12", "@better-auth/kysely-adapter": "1.6.12", "@better-auth/memory-adapter": "1.6.12", "@better-auth/mongo-adapter": "1.6.12", "@better-auth/prisma-adapter": "1.6.12", "@better-auth/telemetry": "1.6.12", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-vJG8hB+zcayZEJgcWGTzP2XODZuf/WKViOtam+uhhQ9879yc7fDWAV9O4jSs+R28noSXIAaB3zhIMN3DaDO3cA=="], + + "better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], @@ -2859,6 +3056,8 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -3127,6 +3326,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], @@ -3169,6 +3370,8 @@ "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="], @@ -3515,6 +3718,8 @@ "leva": ["leva@0.10.1", "", { "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", "@stitches/react": "^1.2.8", "@use-gesture/react": "^10.2.5", "colord": "^2.9.2", "dequal": "^2.0.2", "merge-value": "^1.0.0", "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", "zustand": "^3.6.9" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA=="], + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -3801,6 +4006,8 @@ "nanoid": ["nanoid@5.1.9", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw=="], + "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -3815,6 +4022,10 @@ "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], @@ -3977,6 +4188,10 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -4021,6 +4236,8 @@ "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -4261,6 +4478,14 @@ "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + "rosie-skills": ["rosie-skills@0.6.4", "", { "optionalDependencies": { "rosie-skills-darwin-arm64": "0.6.4", "rosie-skills-freebsd-x64": "0.6.4", "rosie-skills-linux-x64": "0.6.4" }, "bin": { "rosie-skills": "dist/bin.js" } }, "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA=="], + + "rosie-skills-darwin-arm64": ["rosie-skills-darwin-arm64@0.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg=="], + + "rosie-skills-freebsd-x64": ["rosie-skills-freebsd-x64@0.6.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng=="], + + "rosie-skills-linux-x64": ["rosie-skills-linux-x64@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng=="], + "rou3": ["rou3@0.6.3", "", {}, "sha512-1HSG1ENTj7Kkm5muMnXuzzfdDOf7CFnbSYFA+H3Fp/rB9lOCxCPgy1jlZxTKyFoC5jJay8Mmc+VbPLYRjzYLrA=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], @@ -4307,6 +4532,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-value": ["set-value@2.0.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", "is-plain-object": "^2.0.3", "split-string": "^3.0.1" } }, "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw=="], @@ -4661,6 +4888,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "web-vitals": ["web-vitals@5.2.0", "", {}, "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -4683,9 +4912,9 @@ "window-size": ["window-size@1.1.1", "", { "dependencies": { "define-property": "^1.0.0", "is-number": "^3.0.0" }, "bin": { "window-size": "cli.js" } }, "sha512-5D/9vujkmVQ7pSmc0SCBmHXbkv6eaHwXEx65MywhmUMsI8sGqJ972APq1lotfcwMKPFLuCFfL8xGHLIp7jaBmA=="], - "workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], + "workerd": ["workerd@1.20260526.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260526.1", "@cloudflare/workerd-darwin-arm64": "1.20260526.1", "@cloudflare/workerd-linux-64": "1.20260526.1", "@cloudflare/workerd-linux-arm64": "1.20260526.1", "@cloudflare/workerd-windows-64": "1.20260526.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-IHzymht98p10JH1zzwdCpbViAqw97HrwKl7+KfZeASFMsYSrIsAULWdPn0LRC5FTUzBpamLNyKCCKxbgXHgRHQ=="], - "wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="], + "wrangler": ["wrangler@4.95.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260526.0", "path-to-regexp": "6.3.0", "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", "workerd": "1.20260526.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260526.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-vgXzFVSCdUbeCadgVXvu8fK5tzNm8T9W+7lriyGWZMx0B1+CAdr4d8JTlZszHfgjypRAHmAxb49etZGIRD9pgg=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -4761,6 +4990,8 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@better-auth/core/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], @@ -4773,6 +5004,8 @@ "@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="], + "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], @@ -4847,6 +5080,10 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@libsql/isomorphic-ws/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "@libsql/kysely-libsql/@libsql/client": ["@libsql/client@0.8.1", "", { "dependencies": { "@libsql/core": "^0.8.1", "@libsql/hrana-client": "^0.6.2", "js-base64": "^3.7.5", "libsql": "^0.3.10", "promise-limit": "^2.7.0" } }, "sha512-xGg0F4iTDFpeBZ0r4pA6icGsYa5rG6RAG+i/iLDnpCAnSuTqEWMDdPlVseiq4Z/91lWI9jvvKKiKpovqJ1kZWA=="], + "@lobehub/fluent-emoji/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], @@ -5053,6 +5290,10 @@ "atmn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -5163,6 +5404,8 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -5179,6 +5422,8 @@ "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + "miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "node-gyp/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], @@ -5199,6 +5444,8 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], @@ -5287,6 +5534,8 @@ "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "wrangler/miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -5307,6 +5556,8 @@ "@cloudflare/vite-plugin/miniflare/workerd": ["workerd@1.20260415.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260415.1", "@cloudflare/workerd-darwin-arm64": "1.20260415.1", "@cloudflare/workerd-linux-64": "1.20260415.1", "@cloudflare/workerd-linux-arm64": "1.20260415.1", "@cloudflare/workerd-windows-64": "1.20260415.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ=="], + "@cloudflare/vite-plugin/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + "@cloudflare/vite-plugin/wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "@cloudflare/vite-plugin/wrangler/workerd": ["workerd@1.20260415.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260415.1", "@cloudflare/workerd-darwin-arm64": "1.20260415.1", "@cloudflare/workerd-linux-64": "1.20260415.1", "@cloudflare/workerd-linux-arm64": "1.20260415.1", "@cloudflare/workerd-windows-64": "1.20260415.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ=="], @@ -5363,6 +5614,10 @@ "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], + "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], @@ -5445,6 +5700,12 @@ "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "@libsql/kysely-libsql/@libsql/client/@libsql/core": ["@libsql/core@0.8.1", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-u6nrj6HZMTPsgJ9EBhLzO2uhqhlHQJQmVHV+0yFLvfGf3oSP8w7TjZCNUgu1G8jHISx6KFi7bmcrdXW9lRt++A=="], + + "@libsql/kysely-libsql/@libsql/client/@libsql/hrana-client": ["@libsql/hrana-client@0.6.2", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.2.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-MWxgD7mXLNf9FXXiM0bc90wCjZSpErWKr5mGza7ERy2FJNNMXd7JIOv+DepBA1FQTIfI8TFO4/QDYgaQC0goNw=="], + + "@libsql/kysely-libsql/@libsql/client/libsql": ["libsql@0.3.19", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2", "libsql": "^0.3.15" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.3.19", "@libsql/darwin-x64": "0.3.19", "@libsql/linux-arm64-gnu": "0.3.19", "@libsql/linux-arm64-musl": "0.3.19", "@libsql/linux-x64-gnu": "0.3.19", "@libsql/linux-x64-musl": "0.3.19", "@libsql/win32-x64-msvc": "0.3.19" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-Aj5cQ5uk/6fHdmeW0TiXK42FqUlwx7ytmMLPSaUQPin5HKKKuUPD62MAbN4OEweGBBI7q1BekoEN4gPUEL6MZA=="], + "@lobehub/ui/@base-ui/react/@base-ui/utils": ["@base-ui/utils@0.2.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-/CguQ2PDaOzeVOkllQR8nocJ0FFIDqsWIcURsVmm53QGo8NhFNpePjNlyPIB41luxfOqnG7PU0xicMEw3ls7XQ=="], "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -5687,6 +5948,16 @@ "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], + + "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], + + "miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], + + "miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], + + "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -5787,6 +6058,10 @@ "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "wrangler/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + + "wrangler/miniflare/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -5867,12 +6142,38 @@ "@cloudflare/vite-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ=="], + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], + "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@inquirer/core/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "@libsql/kysely-libsql/@libsql/client/libsql/@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.3.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rmOqsLcDI65zzxlUOoEiPJLhqmbFsZF6p4UJQ2kMqB+Kc0Rt5/A1OAdOZ/Wo8fQfJWjR1IbkbpEINFioyKf+nQ=="], + + "@libsql/kysely-libsql/@libsql/client/libsql/@libsql/darwin-x64": ["@libsql/darwin-x64@0.3.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-q9O55B646zU+644SMmOQL3FIfpmEvdWpRpzubwFc2trsa+zoBlSkHuzU9v/C+UNoPHQVRMP7KQctJ455I/h/xw=="], + + "@libsql/kysely-libsql/@libsql/client/libsql/@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.3.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-mgeAUU1oqqh57k7I3cQyU6Trpdsdt607eFyEmH5QO7dv303ti+LjUvh1pp21QWV6WX7wZyjeJV1/VzEImB+jRg=="], + + "@libsql/kysely-libsql/@libsql/client/libsql/@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.3.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-VEZtxghyK6zwGzU9PHohvNxthruSxBEnRrX7BSL5jQ62tN4n2JNepJ6SdzXp70pdzTfwroOj/eMwiPt94gkVRg=="], + + "@libsql/kysely-libsql/@libsql/client/libsql/@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.3.19", "", { "os": "linux", "cpu": "x64" }, "sha512-2t/J7LD5w2f63wGihEO+0GxfTyYIyLGEvTFEsMO16XI5o7IS9vcSHrxsvAJs4w2Pf907uDjmc7fUfMg6L82BrQ=="], + + "@libsql/kysely-libsql/@libsql/client/libsql/@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.3.19", "", { "os": "linux", "cpu": "x64" }, "sha512-BLsXyJaL8gZD8+3W2LU08lDEd9MIgGds0yPy5iNPp8tfhXx3pV/Fge2GErN0FC+nzt4DYQtjL+A9GUMglQefXQ=="], + + "@libsql/kysely-libsql/@libsql/client/libsql/@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.3.19", "", { "os": "win32", "cpu": "x64" }, "sha512-ay1X9AobE4BpzG0XPw1gplyLZPGHIgJOovvW23gUrukRegiUP62uzhpRbKNogLlUOynyXeq//prHgPXiebUfWg=="], + + "@libsql/kysely-libsql/@libsql/client/libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], + "agents/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "agents/yargs/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], diff --git a/docs/docs.json b/docs/docs.json index 5c0c62ae9..31d5a14d9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -87,6 +87,15 @@ ] } ] + }, + { + "tab": "Self-Host", + "groups": [ + { + "group": "Self-Host", + "pages": ["self-hosting/guide"] + } + ] } ] } diff --git a/docs/self-hosting/guide.mdx b/docs/self-hosting/guide.mdx new file mode 100644 index 000000000..b114a986b --- /dev/null +++ b/docs/self-hosting/guide.mdx @@ -0,0 +1,117 @@ +--- +title: Self-Hosting +description: Run Executor on your own infrastructure in a single container. +--- + +Executor self-hosts as **one container** — the database (SQLite/libSQL), the +QuickJS code sandbox, and the MCP server all run in-process. There is no separate +database, worker, or proxy to operate, and no required configuration: a bare +`docker compose up` boots a working instance and walks you through creating the +admin account in the browser. + +## Quick start + +From a clone of the repository: + +```bash +cd apps/host-selfhost +docker compose up -d --build +``` + +Then open [http://localhost:4788](http://localhost:4788). On a fresh instance +you'll see a **setup screen** — create the first admin account, and you're in. + +That's the whole install. The container persists its data (database and +generated keys) in the `executor-data` volume, so it survives restarts and +upgrades. + +### Without compose + +The compose file just wraps the Dockerfile. To build and run it directly: + +```bash +# Build context is the repo root (the Bun workspace install needs every member). +docker build -f apps/host-selfhost/Dockerfile -t executor-selfhost . + +docker run -d -p 4788:4788 -v executor-data:/data executor-selfhost +``` + +## First-run setup + +The **first person to open the instance creates the admin account** — no +environment variables, no passwords in logs. That account becomes the owner of +the instance's single organization. Once it exists, the setup screen is replaced +by sign-in, and self-service signup is closed. + +If you'd rather provision the admin ahead of time (CI / infra-as-code), set +**both** `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` +before first boot; the instance then skips the browser setup and creates that +admin as the owner. + +## Inviting people + +Open signup is off — after the first admin, people join by redeeming an +**invite link**. + +1. Sign in as the admin and open **Admin** in the nav. +2. Under **Invite links**, create a link (optionally with a label and a role). +3. Send the `/join/` link to the person however you like — Slack, email, + in person. The link itself is the credential; no mail server is involved. +4. They open it, pick their own name/email/password, and land in the + organization. Each link is **single-use** and can be revoked while pending. + +From the same Admin page you can change a member's role or remove them. + +## Configuration + +Every setting is optional. Put overrides in `apps/host-selfhost/.env` (compose +loads it automatically) or pass them as `-e` flags to `docker run`. See +`.env.example` for the full list. + +| Variable | Default | Purpose | +| ---------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `EXECUTOR_WEB_BASE_URL` | `http://localhost:4788` | The public URL browsers use to reach the instance. Must match exactly (scheme + host + port) or browser logins are rejected. Set this when serving behind a domain / TLS. | +| `BETTER_AUTH_SECRET` | generated + persisted | Session secret. Auto-generated under the data volume if unset; set it (≥ 32 chars) to manage it yourself. Rotating it signs everyone out. | +| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` / `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Set **both** to pre-create the admin instead of the browser setup. | +| `EXECUTOR_ORG_NAME` / `EXECUTOR_ORG_SLUG` | `Default` / `default` | Name and slug for the single organization. | +| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private network addresses. Off by default. | +| `PORT` / `EXECUTOR_HOST` | `4788` / `0.0.0.0` (in the image) | Bind port and address. | +| `EXECUTOR_DATA_DIR` | `/data` (in the image) | Where the database and keys live. | + +## Reverse proxy and HTTPS + +For anything beyond a local trial, terminate TLS at a reverse proxy (Caddy, +nginx, Traefik) in front of the container and set `EXECUTOR_WEB_BASE_URL` to your +public `https://…` URL. That value must match the address users actually load — +it's what cookie-based browser logins are checked against. + +The container exposes `GET /api/health` (a readiness probe used by the built-in +Docker healthcheck) if your proxy or orchestrator wants one. + +## Backup and restore + +All state lives in the data volume (`/data`): the SQLite database plus the +generated secret keys. Back it up by copying that directory while the container +is stopped (or with your volume tooling): + +```bash +docker compose stop +docker run --rm -v executor-data:/data -v "$PWD":/backup busybox \ + tar czf /backup/executor-backup.tgz -C /data . +docker compose start +``` + +Restore by extracting the archive back into a fresh `executor-data` volume +before starting the container. Keep the backup safe: it contains your secret +keys as well as your data. + +## Upgrading + +```bash +cd apps/host-selfhost +git pull +docker compose up -d --build +``` + +The data volume is reused, so your database and accounts carry over. Schema +migrations run idempotently at boot. diff --git a/package.json b/package.json index dbf619b51..3056682ad 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,8 @@ "bun-types": "^1.2.22", "drizzle-orm": "^0.45.0", "drizzle-kit": "^0.31.10", + "@libsql/client": "^0.17.3", + "@libsql/kysely-libsql": "^0.4.1", "@vitest/expect": "^4.1.5", "@vitest/mocker": "^4.1.5", "@vitest/pretty-format": "^4.1.5", @@ -124,6 +126,7 @@ }, "patchedDependencies": { "postgres@3.4.9": "patches/postgres@3.4.9.patch", - "@cloudflare/vite-plugin@1.31.2": "patches/@cloudflare%2Fvite-plugin@1.31.2.patch" + "@cloudflare/vite-plugin@1.31.2": "patches/@cloudflare%2Fvite-plugin@1.31.2.patch", + "libsql@0.5.29": "patches/libsql@0.5.29.patch" } } diff --git a/packages/core/api/package.json b/packages/core/api/package.json index f3144b3d9..a7c6bde8b 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:" }, diff --git a/packages/core/api/src/account/api.ts b/packages/core/api/src/account/api.ts new file mode 100644 index 000000000..0eaa8181f --- /dev/null +++ b/packages/core/api/src/account/api.ts @@ -0,0 +1,242 @@ +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; + +// --------------------------------------------------------------------------- +// Provider-neutral Account API. +// +// This is the multiplayer "account" surface that BOTH the cloud (WorkOS) and +// self-host (Better Auth) servers implement, at the SAME paths, so the shared +// React UI (shell, api-keys page, org page) is identical for both — only the +// server-side handler implementations and the login UX differ per provider. +// +// Deliberately minimal: it covers exactly what the shared shell + pages need +// (who am I, API keys, org members). Provider-specific surfaces that only one +// product has — cloud's multi-org switcher, WorkOS domains, billing — stay in +// app-local API groups and are wired into the shell through injected slots, +// NOT into this contract. That keeps the shared typed client fully implemented +// by both servers (no half-built HttpApi layers). +// --------------------------------------------------------------------------- + +// ── Neutral errors ───────────────────────────────────────────────────────── +// Each provider maps its native failures (WorkOSError, Better Auth APIError, +// storage faults) onto these at the handler boundary, so the UI handles one +// neutral shape. + +export class AccountError extends Schema.TaggedErrorClass()( + "AccountError", + { message: Schema.String }, + { httpApiStatus: 500 }, +) {} + +export class AccountForbidden extends Schema.TaggedErrorClass()( + "AccountForbidden", + { message: Schema.optional(Schema.String) }, + { httpApiStatus: 403 }, +) {} + +export class AccountNoOrganization extends Schema.TaggedErrorClass()( + "AccountNoOrganization", + {}, + { httpApiStatus: 403 }, +) {} + +export class AccountUnauthorized extends Schema.TaggedErrorClass()( + "AccountUnauthorized", + {}, + { httpApiStatus: 401 }, +) {} + +// ── Shared shapes ──────────────────────────────────────────────────────────── + +export const AccountUser = Schema.Struct({ + id: Schema.String, + email: Schema.String, + name: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), +}); + +export const AccountOrganization = Schema.Struct({ + id: Schema.String, + name: Schema.String, +}); + +export const AccountMeResponse = Schema.Struct({ + user: AccountUser, + organization: Schema.NullOr(AccountOrganization), +}); + +export const ApiKeySummary = Schema.Struct({ + id: Schema.String, + name: Schema.String, + /** Masked display value (e.g. "exk_…a1b2"). The full secret is only ever + * returned once, from `createApiKey`. */ + obfuscatedValue: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + lastUsedAt: Schema.NullOr(Schema.String), +}); + +export const ApiKeysResponse = Schema.Struct({ + apiKeys: Schema.Array(ApiKeySummary), +}); + +export const CreateApiKeyBody = Schema.Struct({ + name: Schema.String, +}); + +/** Create returns the summary PLUS the one-time plaintext `value`. */ +export const CreatedApiKeyResponse = Schema.Struct({ + id: Schema.String, + name: Schema.String, + obfuscatedValue: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + lastUsedAt: Schema.NullOr(Schema.String), + value: Schema.String, +}); + +export const OrgMember = Schema.Struct({ + id: Schema.String, + userId: Schema.String, + email: Schema.String, + name: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + role: Schema.String, + status: Schema.String, + lastActiveAt: Schema.NullOr(Schema.String), + isCurrentUser: Schema.Boolean, +}); + +/** Seat usage. Self-host (unlimited) reports `unlimited: true`; cloud reports + * real plan seats. Optional so providers without a seat model can omit it. */ +export const OrgMemberSeats = Schema.Struct({ + used: Schema.Number, + granted: Schema.Number, + unlimited: Schema.Boolean, +}); + +export const OrgMembersResponse = Schema.Struct({ + members: Schema.Array(OrgMember), + seats: Schema.optional(OrgMemberSeats), +}); + +export const OrgRole = Schema.Struct({ + slug: Schema.String, + name: Schema.String, +}); + +export const OrgRolesResponse = Schema.Struct({ + roles: Schema.Array(OrgRole), +}); + +export const InviteMemberBody = Schema.Struct({ + email: Schema.String, + roleSlug: Schema.optional(Schema.String), +}); + +export const InviteMemberResponse = Schema.Struct({ + id: Schema.String, + email: Schema.String, +}); + +export const UpdateMemberRoleBody = Schema.Struct({ + roleSlug: Schema.String, +}); + +export const UpdateOrgNameBody = Schema.Struct({ + name: Schema.String, +}); + +export const UpdateOrgNameResponse = Schema.Struct({ + name: Schema.String, +}); + +export const SuccessResponse = Schema.Struct({ + success: Schema.Boolean, +}); + +const ApiKeyParams = { apiKeyId: Schema.String }; +const MembershipParams = { membershipId: Schema.String }; + +// ── Group ──────────────────────────────────────────────────────────────────── + +/** + * The neutral account group. Mounted at `/account/*` by both servers. Auth is + * applied by each server's own session middleware (cookie-based, same-origin), + * so this contract carries no provider-specific auth scheme. + */ +export const AccountApi = HttpApiGroup.make("account") + .add( + HttpApiEndpoint.get("me", "/account/me", { + success: AccountMeResponse, + error: [AccountError, AccountUnauthorized], + }), + ) + .add( + HttpApiEndpoint.get("listApiKeys", "/account/api-keys", { + success: ApiKeysResponse, + error: [AccountError, AccountUnauthorized, AccountNoOrganization], + }), + ) + .add( + HttpApiEndpoint.post("createApiKey", "/account/api-keys", { + payload: CreateApiKeyBody, + success: CreatedApiKeyResponse, + error: [AccountError, AccountUnauthorized, AccountNoOrganization], + }), + ) + .add( + HttpApiEndpoint.delete("revokeApiKey", "/account/api-keys/:apiKeyId", { + params: ApiKeyParams, + success: SuccessResponse, + error: [AccountError, AccountUnauthorized, AccountNoOrganization], + }), + ) + .add( + HttpApiEndpoint.get("listMembers", "/account/members", { + success: OrgMembersResponse, + error: [AccountError, AccountUnauthorized, AccountNoOrganization], + }), + ) + .add( + HttpApiEndpoint.get("listRoles", "/account/roles", { + success: OrgRolesResponse, + error: [AccountError, AccountUnauthorized, AccountNoOrganization], + }), + ) + .add( + HttpApiEndpoint.post("inviteMember", "/account/members/invite", { + payload: InviteMemberBody, + success: InviteMemberResponse, + error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization], + }), + ) + .add( + HttpApiEndpoint.delete("removeMember", "/account/members/:membershipId", { + params: MembershipParams, + success: SuccessResponse, + error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization], + }), + ) + .add( + HttpApiEndpoint.patch("updateMemberRole", "/account/members/:membershipId/role", { + params: MembershipParams, + payload: UpdateMemberRoleBody, + success: SuccessResponse, + error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization], + }), + ) + .add( + HttpApiEndpoint.patch("updateOrgName", "/account/name", { + payload: UpdateOrgNameBody, + success: UpdateOrgNameResponse, + error: [AccountError, AccountUnauthorized, AccountForbidden, AccountNoOrganization], + }), + ); + +/** + * Standalone HttpApi wrapping just the account group — used to build the shared + * `AccountApiClient` in `@executor-js/react`. Servers don't use this; they add + * `AccountApi` to their own full API so it's served alongside the core groups. + */ +export const AccountHttpApi = HttpApi.make("executor-account").add(AccountApi); diff --git a/packages/core/api/src/account/handlers.ts b/packages/core/api/src/account/handlers.ts new file mode 100644 index 000000000..76e6ea010 --- /dev/null +++ b/packages/core/api/src/account/handlers.ts @@ -0,0 +1,87 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpServerRequest } from "effect/unstable/http"; +import { Effect } from "effect"; + +import { AccountHttpApi } from "./api"; +import { AccountProvider, type AccountHeaders } from "./service"; + +// --------------------------------------------------------------------------- +// Shared, provider-neutral handlers for the Account API. They do nothing but +// read the request headers and delegate to the injected `AccountProvider`, so +// both cloud and self-host serve identical routes — only the service impl +// differs. The neutral errors thrown by the service map directly to their HTTP +// statuses (401/403/500) via the contract annotations. +// --------------------------------------------------------------------------- + +const requestHeaders = Effect.map( + HttpServerRequest.HttpServerRequest.asEffect(), + (req): AccountHeaders => ({ ...req.headers }), +); + +export const AccountHandlers = HttpApiBuilder.group(AccountHttpApi, "account", (handlers) => + handlers + .handle("me", () => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).me(headers); + }), + ) + .handle("listApiKeys", () => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).listApiKeys(headers); + }), + ) + .handle("createApiKey", ({ payload }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).createApiKey(headers, payload.name); + }), + ) + .handle("revokeApiKey", ({ params }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).revokeApiKey(headers, params.apiKeyId); + }), + ) + .handle("listMembers", () => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).listMembers(headers); + }), + ) + .handle("listRoles", () => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).listRoles(headers); + }), + ) + .handle("inviteMember", ({ payload }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).inviteMember(headers, payload); + }), + ) + .handle("removeMember", ({ params }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).removeMember(headers, params.membershipId); + }), + ) + .handle("updateMemberRole", ({ params, payload }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).updateMemberRole( + headers, + params.membershipId, + payload.roleSlug, + ); + }), + ) + .handle("updateOrgName", ({ payload }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AccountProvider).updateOrgName(headers, payload.name); + }), + ), +); diff --git a/packages/core/api/src/account/service.ts b/packages/core/api/src/account/service.ts new file mode 100644 index 000000000..2f81212d6 --- /dev/null +++ b/packages/core/api/src/account/service.ts @@ -0,0 +1,75 @@ +import { Context, type Effect } from "effect"; + +import { + type AccountError, + type AccountForbidden, + type AccountNoOrganization, + type AccountUnauthorized, + AccountMeResponse, + ApiKeysResponse, + CreatedApiKeyResponse, + OrgMembersResponse, + OrgRolesResponse, + InviteMemberResponse, + InviteMemberBody, + SuccessResponse, + UpdateOrgNameResponse, +} from "./api"; + +// --------------------------------------------------------------------------- +// AccountProvider — the provider seam behind the neutral Account API. +// +// The shared `AccountHandlers` (account/handlers.ts) are generic: they read the +// request headers and delegate to this service, mapping nothing. Each product +// provides its own implementation: +// - self-host → Better Auth (auth.api.*) +// - cloud → WorkOS +// This is the server-side analog of the client's neutral contract: one set of +// handlers, two implementations. Methods take the raw request headers (cookie / +// bearer / api-key) so the implementation can act as the calling user. +// --------------------------------------------------------------------------- + +export type AccountHeaders = Record; + +type Me = typeof AccountMeResponse.Type; +type ApiKeys = typeof ApiKeysResponse.Type; +type CreatedApiKey = typeof CreatedApiKeyResponse.Type; +type Members = typeof OrgMembersResponse.Type; +type Roles = typeof OrgRolesResponse.Type; +type Invite = typeof InviteMemberResponse.Type; +type InviteBody = typeof InviteMemberBody.Type; +type Success = typeof SuccessResponse.Type; +type OrgName = typeof UpdateOrgNameResponse.Type; + +type Authed = Effect.Effect; +type OrgScoped = Authed; + +export interface AccountProviderShape { + readonly me: (headers: AccountHeaders) => Authed; + readonly listApiKeys: (headers: AccountHeaders) => OrgScoped; + readonly createApiKey: (headers: AccountHeaders, name: string) => OrgScoped; + readonly revokeApiKey: (headers: AccountHeaders, apiKeyId: string) => OrgScoped; + readonly listMembers: (headers: AccountHeaders) => OrgScoped; + readonly listRoles: (headers: AccountHeaders) => OrgScoped; + readonly inviteMember: ( + headers: AccountHeaders, + body: InviteBody, + ) => OrgScoped; + readonly removeMember: ( + headers: AccountHeaders, + membershipId: string, + ) => OrgScoped; + readonly updateMemberRole: ( + headers: AccountHeaders, + membershipId: string, + roleSlug: string, + ) => OrgScoped; + readonly updateOrgName: ( + headers: AccountHeaders, + name: string, + ) => OrgScoped; +} + +export class AccountProvider extends Context.Service()( + "@executor-js/api/AccountProvider", +) {} diff --git a/packages/core/api/src/client.ts b/packages/core/api/src/client.ts index a1c8ce1d7..89857cbda 100644 --- a/packages/core/api/src/client.ts +++ b/packages/core/api/src/client.ts @@ -7,3 +7,11 @@ export { ExecutionsApi } from "./executions/api"; export { ScopeApi } from "./scope/api"; export { OAuthApi } from "./oauth/api"; export { PoliciesApi } from "./policies/api"; +export { + AccountApi, + AccountHttpApi, + AccountError, + AccountForbidden, + AccountNoOrganization, + AccountUnauthorized, +} from "./account/api"; diff --git a/packages/core/api/src/handlers/connection-identity.ts b/packages/core/api/src/handlers/connection-identity.ts index bef8c5795..2ab903600 100644 --- a/packages/core/api/src/handlers/connection-identity.ts +++ b/packages/core/api/src/handlers/connection-identity.ts @@ -1,13 +1,11 @@ import { Data, Duration, Effect, Exit, Option, Predicate, Schema, type Layer } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { OAUTH2_PROVIDER_KEY, OAuthProviderStateSchema, type Executor } from "@executor-js/sdk"; import { OAUTH2_DEFAULT_TIMEOUT_MS, - OAUTH2_PROVIDER_KEY, - OAuthProviderStateSchema, assertSupportedOAuthEndpointUrl, - type Executor, -} from "@executor-js/sdk"; +} from "@executor-js/sdk/host-internal"; import type { ConnectionId, ScopeId } from "@executor-js/sdk/shared"; import type { ConnectionIdentityResponse } from "../connections/api"; diff --git a/packages/core/api/src/handlers/index.ts b/packages/core/api/src/handlers/index.ts index 1c0b608f0..06b5af670 100644 --- a/packages/core/api/src/handlers/index.ts +++ b/packages/core/api/src/handlers/index.ts @@ -26,6 +26,5 @@ export const CoreHandlers = Layer.mergeAll( ScopeHandlers, ExecutionsHandlers, OAuthHandlers, - OAuthHandlers, PoliciesHandlers, ); diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 36af28026..6403d8d12 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -25,6 +25,32 @@ export { type RunOAuthCallbackInput, } from "./oauth-popup"; export { PoliciesApi } from "./policies/api"; +export { + AccountApi, + AccountHttpApi, + AccountError, + AccountForbidden, + AccountNoOrganization, + AccountUnauthorized, + AccountUser, + AccountOrganization, + AccountMeResponse, + ApiKeySummary, + ApiKeysResponse, + CreateApiKeyBody, + CreatedApiKeyResponse, + OrgMember, + OrgMemberSeats, + OrgMembersResponse, + OrgRole, + OrgRolesResponse, + InviteMemberBody, + InviteMemberResponse, + UpdateMemberRoleBody, + UpdateOrgNameBody, + UpdateOrgNameResponse, + SuccessResponse, +} from "./account/api"; export { InternalError, ErrorCapture, diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index a2ca278ca..52d5f9b49 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -14,3 +14,92 @@ export { providePluginExtensions, type PluginExtensionServices, } from "./plugin-routes"; +export { AccountProvider, type AccountProviderShape, type AccountHeaders } from "./account/service"; +export { AccountHandlers } from "./account/handlers"; +export { requestScopedMiddleware } from "./server/request-scoped"; +export { RouterConfigLive } from "./server/router-config"; +export { consoleErrorCapture } from "./server/console-error-capture"; +export { + makeExecutionStack, + CodeExecutorProvider, + EngineDecorator, + EngineDecoratorNoop, + type CodeExecutor, + type EngineDecoratorShape, + type EngineStackIdentity, +} from "./server/execution-stack"; +export { + makeMcpBuildServer, + makeConsoleMcpErrorReporter, + type McpExecutionStackLayer, +} from "./server/mcp-build"; +// Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author +// contract) into this host surface. The pure FumaDB assembly (`createExecutorFumaDb` +// + its types) keeps its definition in the SDK for the sqlite test backend and is +// re-exported here so hosts get the assembly AND the `DbProvider` seam from one +// place. `collectTables` keeps its definition in the SDK (it is part of +// `createExecutor`'s mechanics) and is re-exported here for hosts/tooling. +export { + createExecutorFumaDb, + dbProviderLayer, + DbProvider, + type CreateExecutorFumaDbOptions, + type ExecutorDbHandle, + type ExecutorDbProvider, + type ExecutorFumaDb, + type ExecutorFumaSchema, +} from "./server/executor-fuma-db"; +export { + makeScopedExecutor, + HostConfig, + PluginsProvider, + RequestWebOrigin, + type HostConfigShape, + type PluginsProviderShape, + type RequestWebOriginShape, +} from "./server/scoped-executor"; +export { collectTables } from "@executor-js/sdk"; +export { + IdentityProvider, + AuthContext, + Unauthorized, + NoOrganization, + Unavailable, + authContextFromPrincipal, + type Principal, + type IdentityProviderShape, + type IdentityFailure, +} from "./server/identity"; +export { + makeExecutionStackMiddleware, + textFailureStrategy, + type FailureRenderingStrategy, + type MakeExecutionStackMiddlewareOptions, +} from "./server/execution-stack-middleware"; +export { + makeFixedExecutionMiddleware, + FixedExecutionProvider, + type FixedExecution, + type MakeFixedExecutionMiddlewareOptions, +} from "./server/fixed-execution-middleware"; +export { + makeProtectedApiLayer, + makeAccountApiLayer, + accountProviderMiddlewareLayer, + toApiHandler, + type MakeProtectedApiLayerOptions, + type MakeAccountApiLayerOptions, + type ApiHandler, +} from "./server/host-foundation"; +export * as ExecutorApp from "./server/executor-app"; +export type { + ExecutorAppOptions, + AppProviders, + CommonProviders, + ScopedExecutionProviders, + FixedExecutionProviders, + AppExtensions, + AppConfig, + EngineProviders, + McpProviders, +} from "./server/executor-app"; diff --git a/packages/core/api/src/server/console-error-capture.ts b/packages/core/api/src/server/console-error-capture.ts new file mode 100644 index 000000000..1717ffe90 --- /dev/null +++ b/packages/core/api/src/server/console-error-capture.ts @@ -0,0 +1,39 @@ +// --------------------------------------------------------------------------- +// Console `ErrorCapture` factory. +// +// Prints the squashed + pretty-printed structured cause to stderr and returns +// a short correlation id that surfaces in the opaque 500 traceId, so operators +// can grep their logs/terminal scrollback when a user reports a traceId. Hosts +// that want richer reporting (cloud: Sentry) swap in their own adapter behind +// the same `ErrorCapture` tag. +// +// The `prefix` distinguishes which host emitted the id (e.g. `selfhost`, +// `local`). +// --------------------------------------------------------------------------- + +import { Cause, Effect, Layer } from "effect"; + +import { ErrorCapture } from "../observability"; + +export const consoleErrorCapture = (prefix: string): Layer.Layer => { + const nextTraceId = () => + `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + + return Layer.succeed( + ErrorCapture, + ErrorCapture.of({ + captureException: (cause) => + Effect.sync(() => { + const traceId = nextTraceId(); + const squashed = Cause.squash(cause); + console.error( + `[executor ${traceId}]`, + // oxlint-disable-next-line executor/no-instanceof-error -- boundary: console logger preserves native Error stack output + squashed instanceof Error ? (squashed.stack ?? squashed) : squashed, + ); + console.error(`[executor ${traceId}] cause:`, Cause.pretty(cause)); + return traceId; + }), + }), + ); +}; diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts new file mode 100644 index 000000000..2c86ce7c6 --- /dev/null +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -0,0 +1,199 @@ +// --------------------------------------------------------------------------- +// Shared executor-API ExecutionStackMiddleware. +// +// Cloud and self-host had a structurally identical `HttpRouter` middleware that, +// per request: +// 1. reads the inbound `HttpServerRequest`, converts it to a web `Request`, +// 2. resolves identity (api-key/session for cloud, cookie/bearer/x-api-key for +// self-host) into a neutral `Principal`, +// 3. builds the per-(user, org) executor + engine via `makeExecutionStack`, +// 4. provides `AuthContext` + the execution-stack services + every plugin +// extension Service to the wrapped handler. +// +// This factory owns that common body. The differences are injected: +// - `authenticate` — the provider's resolve fn. BOTH apps yield the neutral +// `Principal` and fail the SHARED `Unauthorized | +// NoOrganization | Unavailable` (cloud: WorkOS api-key/ +// sealed-session; self-host: Better Auth cookie/bearer/ +// x-api-key). The credential precedence stays INSIDE each +// impl. +// - `renderFailure` — the failure-rendering strategy. Cloud renders the +// shared errors as its exact `{ error, code }` JSON at +// 401/403/503; self-host catches them into 401/403/503 +// text. The seam (request -> Principal | shared error) is +// identical; only the rendering differs. +// - `plugins` — the host's plugin tuple (typed extension Services). +// - `stackLayer` — the host's `makeExecutionStack` seam Layer (cloud: +// `CloudExecutionStackLayer`; self-host: +// `SelfHostExecutionStackLayer`). +// +// `LongLived` is the boot-scoped context captured at layer-build time (the +// provider tag + the stack's long-lived deps) so the per-request function only +// depends on `HttpRouter`-provided context. The returned value is the +// `HttpRouter.middleware` (NOT `.layer`) so a host can still `.combine(...)` a +// request-scoped middleware into it (cloud folds its per-request DB layer). +// --------------------------------------------------------------------------- + +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { Context, Effect, Layer } from "effect"; + +import type { AnyPlugin } from "@executor-js/sdk"; + +import type { DbProvider } from "./executor-fuma-db"; +import { RequestWebOrigin, type HostConfig, type PluginsProvider } from "./scoped-executor"; +import { ExecutionEngineService, ExecutorService } from "../services"; +import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; +import { + authContextFromPrincipal, + AuthContext, + type IdentityFailure, + type Principal, +} from "./identity"; +import { + makeExecutionStack, + type CodeExecutorProvider, + type EngineDecorator, +} from "./execution-stack"; + +/** + * A failure-rendering strategy. `renderFailure` runs on the result of + * `authenticate`: it MUST either re-raise the failure (so a `Respondable` typed + * error reaches the framework's response pipeline — cloud) or recover it into a + * concrete `HttpServerResponse` (self-host's explicit 401/403 text). `RR` is the + * residual requirement the strategy adds (always `never` in practice). + */ +export interface FailureRenderingStrategy { + readonly renderFailure: ( + effect: Effect.Effect, + ) => Effect.Effect; +} + +/** + * Self-host's strategy: this is an `HttpRouter` middleware (not an `HttpApi` + * endpoint), so a failed typed error would surface as a 500 — recover + * `Unauthorized` -> 401 text and `NoOrganization` -> 403 text instead. Self-host + * never produces `Unavailable`, but the shared channel now includes it, so it is + * recovered to a 503 text for total coverage. + */ +export const textFailureStrategy: FailureRenderingStrategy = { + renderFailure: (effect) => + effect.pipe( + Effect.catchTags({ + Unauthorized: () => + Effect.succeed(HttpServerResponse.text("Unauthorized", { status: 401 })), + NoOrganization: () => + Effect.succeed( + HttpServerResponse.text("No organization for this account", { + status: 403, + }), + ), + Unavailable: () => + Effect.succeed( + HttpServerResponse.text("Authentication temporarily unavailable", { + status: 503, + }), + ), + }), + ), +}; + +export interface MakeExecutionStackMiddlewareOptions< + TPlugins extends readonly AnyPlugin[], + E, + RLong, + RStack, + RStrategy, +> { + /** The host's plugin tuple — drives the typed extension Services and binding. */ + readonly plugins: TPlugins; + /** + * Resolve the inbound web `Request` to a neutral `Principal`. Adapter-specific + * credential precedence stays inside this function. + */ + readonly authenticate: (request: Request) => Effect.Effect; + /** Render `authenticate` failures (passthrough for cloud, text for self-host). */ + readonly strategy: FailureRenderingStrategy; + /** The host's `makeExecutionStack` seam Layer. */ + readonly stackLayer: Layer.Layer< + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator, + never, + RStack + >; +} + +/** + * Build the shared `ExecutionStackMiddleware`. `RCapture` is the boot-scoped + * context captured ONCE at layer-build time; anything the per-request body still + * needs (`RLong | RStack | RStrategy` minus `RCapture`) stays a residual + * requirement of the returned middleware, satisfied per request by the host. + * + * - self-host captures everything (`AuthProvider | SelfHostDb`): no residual, + * so `.layer` is a complete Layer. + * - cloud captures only the boot-scoped services (its identity provider + the + * app-only billing service its metered stack reads) and leaves `DbService` + * residual, satisfied per request by `.combine(requestScopedMiddleware(rsLive))` + * (so the postgres.js socket lives in the request fiber's scope). + * + * The returned value is the `HttpRouter.middleware` (NOT `.layer`) so cloud can + * still `.combine(...)`. + */ +export const makeExecutionStackMiddleware = < + const TPlugins extends readonly AnyPlugin[], + E, + RLong = never, + RStack = never, + RStrategy = never, + RCapture = RLong | RStack | RStrategy, +>( + options: MakeExecutionStackMiddlewareOptions, +) => { + const provideExecutorExtensions = providePluginExtensions(options.plugins); + return HttpRouter.middleware<{ + provides: + | AuthContext + | ExecutorService + | ExecutionEngineService + | PluginExtensionServices; + }>()( + Effect.gen(function* () { + const captured = yield* Effect.context(); + return (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const webRequest = yield* HttpServerRequest.toWeb(request); + const resolved = yield* options.strategy.renderFailure(options.authenticate(webRequest)); + // The strategy recovered the failure into a Response — return it. + if (!isPrincipal(resolved)) return resolved; + const auth = AuthContext.of(authContextFromPrincipal(resolved)); + // The public origin the caller actually hit, so a host with no static + // web base URL (a Worker) derives one zero-config. An explicit + // `HostConfig.webBaseUrl` still wins; we deliberately read `request.url` + // (not a spoofable `X-Forwarded-Host`). + const { executor, engine } = yield* makeExecutionStack( + resolved.accountId, + resolved.organizationId, + resolved.organizationName, + ).pipe( + Effect.provide(options.stackLayer), + Effect.provideService(RequestWebOrigin, { origin: new URL(webRequest.url).origin }), + ); + return yield* httpEffect.pipe( + Effect.provideService(AuthContext, auth), + Effect.provideService(ExecutorService, executor), + Effect.provideService(ExecutionEngineService, engine), + provideExecutorExtensions(executor), + ); + // Provide the boot-captured context; uncaptured deps (cloud's + // request-scoped `DbService`) remain residual and flow through here. + }).pipe(Effect.provideContext(captured as Context.Context)); + }), + ); +}; + +// `renderFailure` yields either the resolved `Principal` (proceed) or an +// already-built `HttpServerResponse` (the strategy recovered the failure). A +// `Principal` is a plain object with `accountId`; a response is tagged. Discern +// by the marker the response framework brands its values with. +const isPrincipal = ( + value: Principal | HttpServerResponse.HttpServerResponse, +): value is Principal => !HttpServerResponse.isHttpServerResponse(value); diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts new file mode 100644 index 000000000..6e58801fc --- /dev/null +++ b/packages/core/api/src/server/execution-stack.ts @@ -0,0 +1,117 @@ +// --------------------------------------------------------------------------- +// Shared execution stack — turn a (user, org) into a runnable executor + engine. +// +// Cloud and self-host both had an identical `makeExecutionStack`: +// createScopedExecutor -> createExecutionEngine({ executor, codeExecutor }) -> +// { executor, engine } +// differing only in (a) the code substrate (cloud's Cloudflare dynamic-worker vs +// self-host's in-process QuickJS) and (b) cloud's usage-metering decorator +// (an app-only billing overlay), absent on self-host. +// +// This factory owns the common body. The two differences are injected: +// - `CodeExecutorProvider` — the `codeExecutor` value. Cloud's Layer wraps +// `makeDynamicWorkerExecutor({ loader: env.LOADER })`; self-host's wraps +// `makeQuickJsExecutor()`. +// - `EngineDecorator` — `decorate(engine) => engine`. Cloud's app layer applies +// a usage-metering overlay; the default Layer is a no-op (self-host, local, +// tests, and cloud's non-metering MCP session path). +// +// The per-(user, org) executor itself comes from `makeScopedExecutor` (sdk), +// which reads the DB handle / plugins / host config from its own seams. This +// lives in `@executor-js/api` because it is the only package that depends on +// both `@executor-js/sdk` (for `makeScopedExecutor`) and `@executor-js/execution` +// (for `createExecutionEngine`). +// --------------------------------------------------------------------------- + +import { Context, Effect, Layer } from "effect"; +import type * as Cause from "effect/Cause"; + +import type { AnyPlugin, Executor, StorageFailure } from "@executor-js/sdk"; +import { + createExecutionEngine, + type ExecutionEngine, + type ExecutionEngineConfig, +} from "@executor-js/execution"; + +import { DbProvider } from "./executor-fuma-db"; +import { HostConfig, PluginsProvider, makeScopedExecutor } from "./scoped-executor"; + +// --------------------------------------------------------------------------- +// CodeExecutorProvider seam — the host's code-execution substrate. Typed to the +// widened `Cause.YieldableError` channel (matching `ExecutionEngineService`) so +// a runtime-specific tagged error (DynamicWorkerExecutionError, QuickJS errors) +// assigns structurally. +// --------------------------------------------------------------------------- + +export type CodeExecutor = ExecutionEngineConfig["codeExecutor"]; + +export class CodeExecutorProvider extends Context.Service()( + "@executor-js/api/CodeExecutorProvider", +) {} + +// --------------------------------------------------------------------------- +// EngineDecorator seam — wrap the freshly built engine (e.g. with usage +// metering). `decorate` receives the same `(accountId, organizationId, +// organizationName)` identity the stack was built for, so a host can bind the +// decorator to the org (cloud's per-org usage metering needs the org id). The +// default Layer is a no-op so hosts that do not decorate (self-host, local, +// tests) get an identity transform for free. +// --------------------------------------------------------------------------- + +export interface EngineStackIdentity { + readonly accountId: string; + readonly organizationId: string; + readonly organizationName: string; +} + +export interface EngineDecoratorShape { + readonly decorate: ( + engine: ExecutionEngine, + identity: EngineStackIdentity, + ) => ExecutionEngine; +} + +export class EngineDecorator extends Context.Service()( + "@executor-js/api/EngineDecorator", +) {} + +/** No-op decorator: the engine passes through unchanged. */ +export const EngineDecoratorNoop: Layer.Layer = Layer.succeed(EngineDecorator)({ + decorate: (engine) => engine, +}); + +// --------------------------------------------------------------------------- +// makeExecutionStack — shared (user, org) -> { executor, engine }. +// +// Reads `makeScopedExecutor` (sdk), the code substrate from +// `CodeExecutorProvider`, and the engine wrap from `EngineDecorator`. The +// returned engine error channel is widened to `Cause.YieldableError`, matching +// `ExecutionEngineService` and the runtime-specific code executors. +// --------------------------------------------------------------------------- + +export const makeExecutionStack = < + const TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[], +>( + accountId: string, + organizationId: string, + organizationName: string, +): Effect.Effect< + { readonly executor: Executor; readonly engine: ExecutionEngine }, + StorageFailure, + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator +> => + Effect.gen(function* () { + const executor = yield* makeScopedExecutor( + accountId, + organizationId, + organizationName, + ); + const codeExecutor = yield* CodeExecutorProvider; + const { decorate } = yield* EngineDecorator; + const engine = decorate(createExecutionEngine({ executor, codeExecutor }), { + accountId, + organizationId, + organizationName, + }); + return { executor, engine }; + }); diff --git a/packages/core/api/src/server/executor-app.ts b/packages/core/api/src/server/executor-app.ts new file mode 100644 index 000000000..622412df2 --- /dev/null +++ b/packages/core/api/src/server/executor-app.ts @@ -0,0 +1,594 @@ +// --------------------------------------------------------------------------- +// ExecutorApp.make — the single composition facade every product host calls. +// +// One codebase, three scenarios: cloud / self-host / local are the SAME code +// paths; the difference is a list of injected Layers. `ExecutorApp.make` is the +// shared assembly those Layers slot into — a newcomer reads ONE `make({ … })` +// call and sees the whole scenario (which identity, which DB, which code +// substrate, which MCP, billing present or absent). +// +// It does exactly what each host's hand-rolled composition root did before: +// +// 1. execution stack Layer = db + engine.codeExecutor + engine.decorator +// + plugins.provider + plugins.config (the makeExecutionStack seams) +// 2. ExecutionStackMiddleware = makeExecutionStackMiddleware(identity-authenticate +// + that stack + plugin tuple + failure strategy) (auth + per-request executor) +// 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture, +// router: prefixed(mountPrefix) }) wrapped by (2) +// 4. the MCP serving envelope = McpServingRoutes + the 2-3 seams (auth/sessions +// /reporter), double-provided like the host did (the seams) +// 5. the account API = makeAccountApiLayer(accountMiddleware, { router }) +// 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn) +// 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer +// 8. toApiHandler(appLayer) -> { handler, dispose } (web-handler binding) +// +// SEAM vs EXTENSION (the grouping teaches the line): +// - `providers.*` = named slots whose Layer satisfies a tag the shared core +// RESOLVES (identity, account, db, engine, mcp, plugins, +// errorCapture). The app picks the impl; the core names the +// tag. `errorCapture` IS a seam — the core resolves it. +// - `extensions.*` = surface the core never names (routes/services). Better +// Auth's /api/auth handler, Swagger, cloud's marketing + +// /autumn billing live here. The shared core never imports +// them. +// +// `mountPrefix` is a STRING ("/api"); make() builds the `router.prefixed(...)` +// view internally so a host never hand-writes path stripping. `mcpExport` is the +// escape hatch for a platform-only export (cloud's Durable Object class) that the +// runtime needs surfaced but the shared core never names — make() passes it back +// out untouched. +// --------------------------------------------------------------------------- + +import { HttpRouter } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; + +import type { AnyPlugin } from "@executor-js/sdk"; +import type { DbProvider } from "./executor-fuma-db"; +import type { HostConfig, PluginsProvider } from "./scoped-executor"; +import { requestScopedMiddleware } from "./request-scoped"; +import { + McpServingRoutes, + McpErrorReporterNoop, + type McpAuthProvider, + type McpErrorReporter, + type McpSessionStore, +} from "@executor-js/host-mcp"; + +import { composePluginApi } from "../plugin-routes"; +import type { ErrorCapture } from "../observability"; +import { + EngineDecoratorNoop, + type CodeExecutorProvider, + type EngineDecorator, +} from "./execution-stack"; +import { + makeExecutionStackMiddleware, + type FailureRenderingStrategy, +} from "./execution-stack-middleware"; +import { makeFixedExecutionMiddleware, FixedExecutionProvider } from "./fixed-execution-middleware"; +import { IdentityProvider, type IdentityFailure, type Principal } from "./identity"; +import { + makeAccountApiLayer, + makeProtectedApiLayer, + toApiHandler, + type ApiHandler, +} from "./host-foundation"; + +// A fully-resolved route/app Layer with its channels erased. Used at the +// assembly boundaries (mirrors `toApiHandler`'s loose typing): each host's +// composed set differs (account API present or not, MCP present or not, the +// residual `RDb`/`RAcct` flow varies), but at runtime every requirement is +// provided. Keeping the boundary loose avoids leaking the constrained plugin +// handler-error union into every assembled host layer. +// +// `Layer` — `ROut` is CONTRAVARIANT, so `never` (not +// `any`) is the universal supertype in that slot: every concrete route layer is +// assignable to `Layer`. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AppRouteLayer = Layer.Layer; + +// --------------------------------------------------------------------------- +// Provider seams — the variation points the shared core resolves. +// --------------------------------------------------------------------------- + +/** + * The execution engine seams: the code substrate + the optional decorator. The + * code executor varies per host (QuickJS in-process vs the Cloudflare dynamic + * worker); the decorator wraps the engine for app-only concerns (cloud's usage + * metering) and defaults to the no-op when absent. + * + * This is the SCOPED execution model: the `ExecutionStackMiddleware` builds a + * fresh per-(user, org) executor each request via `makeExecutionStack`. Cloud + * and self-host use it. A host whose executor is a single boot-built instance + * (local) supplies `providers.fixedExecution` instead — see `AppProviders`. + */ +export interface EngineProviders { + /** The code-execution substrate (QuickJS, dynamic worker, …). */ + readonly codeExecutor: Layer.Layer; + /** + * Wraps the built engine; defaults to `EngineDecoratorNoop` (no metering). May + * carry a boot-scoped residual `REngine` (cloud's metering decorator reads the + * `AutumnService` shell), folded into `RDb` and satisfied by `boot`. + */ + readonly decorator?: Layer.Layer; +} + +/** + * The MCP serving seams. Omit the whole group to serve no `/mcp` envelope. The + * reporter defaults to the no-op. + * + * `RMcpAuth` is the auth seam's residual requirement (default `never`). The + * facade ALWAYS provides `providers.identity` to it (a harmless no-op when the + * seam ignores it), so a host whose MCP auth genuinely reads the neutral + * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose + * MCP plane is a separate credential surface leaves it `never` (cloud). + */ +export interface McpProviders { + /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */ + readonly auth: Layer.Layer; + /** Owns the entire serving-session lifecycle (in-process Map vs DO). */ + readonly sessions: Layer.Layer; + /** Forward an orchestration defect to the host's capture; default no-op. */ + readonly reporter?: Layer.Layer; +} + +/** + * The provider seams common to BOTH execution models (scoped + fixed): identity, + * the optional account API, the optional MCP envelope, and error capture. The + * execution-specific seams live on the two variant interfaces below. + */ +export interface CommonProviders { + /** + * The neutral `IdentityProvider` seam Layer. EVERY host provides the SAME tag: + * self-host's Better Auth layer, cloud's `workosIdentityLayer`, and local's + * single-user provider are implementations of one seam. The facade ALWAYS + * builds the `authenticate` resolver by reading this tag, so a host never + * hand-writes one. + * + * `RIdentity` is the layer's own residual requirement (cloud's per-request + * `UserStoreService`/`DbService`; `never` for self-host and local). The facade + * provides this layer PER REQUEST over `requestScoped` (which carries + * `RIdentity`), so the resolver runs in the request fiber where the identity + * layer's deps (the postgres socket) live. A `RIdentity = never` layer is + * provided directly with no per-request dependency. + */ + readonly identity: Layer.Layer; + /** + * The account-API middleware Layer (provides `AccountProvider` per request via + * a `Request<"Requires", AccountProvider>` marker). Omit to serve no account + * API (self-contained / local). `RAcct` is its residual requirement, satisfied + * by `boot` / `requestScoped`. The output is left open (`ROut` is + * contravariant, so `never` accepts any middleware-marker layer). + */ + readonly account?: Layer.Layer; + /** The MCP serving seams; omit to serve no `/mcp` envelope. */ + readonly mcp?: McpProviders; + /** The `ErrorCapture` seam (console vs Sentry) — the core resolves it. */ + readonly errorCapture: Layer.Layer; +} + +/** + * The SCOPED execution provider seams (cloud + self-host): a per-request + * executor is built from the resolved `Principal` over the DB handle, the plugin + * data seams, and the engine substrate. `RDb` is the boot-scoped residual these + * seams leave (self-host's long-lived `SelfHostDb` handle, cloud's metering + * decorator's `AutumnService`), satisfied by `boot`. + */ +export interface ScopedExecutionProviders { + /** The `DbProvider` seam (may require `boot`'s long-lived handle). */ + readonly db: Layer.Layer; + /** + * The code-execution engine seams. The optional decorator may carry a + * boot-scoped residual (cloud's metering decorator's `AutumnService`), folded + * into `RDb`. + */ + readonly engine: EngineProviders; + /** The plugin data seams (PluginsProvider + HostConfig). */ + readonly plugins: { + readonly provider: Layer.Layer; + readonly config: Layer.Layer; + }; + /** Distinct from the fixed shape — never set here. */ + readonly fixedExecution?: undefined; +} + +/** + * The FIXED execution provider seam (local): the host builds ONE executor + + * engine at boot (single cwd scope + `allowHttp`) and shares it across every + * request. No per-request scope-stack rebuild, no `DbProvider`/`PluginsProvider`/ + * `HostConfig`/`CodeExecutorProvider` seams (the executor already holds its db, + * plugins, and code substrate). The facade still runs the identity seam per + * request to build `AuthContext`, then provides this constant executor/engine. + */ +export interface FixedExecutionProviders { + /** The boot-built executor + engine + plugin extension map, as one seam. */ + readonly fixedExecution: Layer.Layer; + /** Distinct from the scoped shape — never set here. */ + readonly db?: undefined; + readonly engine?: undefined; + readonly plugins?: undefined; +} + +/** + * Every provider seam, grouped. The execution model is a discriminated union: + * `ScopedExecutionProviders` (cloud + self-host: per-request scoped executor) or + * `FixedExecutionProviders` (local: one boot executor). `RAcct` is the account + * middleware's residual; `RIdentity` the identity seam's own residual; `RMcpAuth` + * the MCP auth seam's residual. + */ +export type AppProviders = CommonProviders< + RAcct, + RIdentity, + RMcpAuth +> & + (ScopedExecutionProviders | FixedExecutionProviders); + +// --------------------------------------------------------------------------- +// Extensions — app-only surface the core never names. +// --------------------------------------------------------------------------- + +/** + * A route extension Layer: registers on the ambient (un-prefixed) `HttpRouter`. + * The requirement channel is left open (`RIn` is covariant) because a route + * handler may carry framework markers — `HttpRouter.HttpRouter` plus, e.g., a + * `Request<"Error", HttpServerError>` marker from `HttpEffect.fromWebHandler` — + * that the serve binding clears. Provides nothing of its own. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RouteExtension = Layer.Layer; + +/** + * App-only HTTP surface mounted alongside the API: each entry registers on the + * ambient (un-prefixed) `HttpRouter`. Better Auth's `/api/auth/*` handler, + * Swagger, cloud's marketing + `/autumn` billing route all live here — the + * shared core never imports them. + */ +export interface AppExtensions { + /** Extra route Layers to merge into the app router. */ + readonly routes?: ReadonlyArray; +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +// The identity seam's failure channel (`Unauthorized | NoOrganization | +// Unavailable`) lives in `./identity` now that BOTH apps provide the neutral +// `IdentityProvider`. The failure strategy renders it: self-host catches it into +// 401/403/503 text (`textFailureStrategy`; it never produces `Unavailable`); +// cloud's strategy renders its exact 401/403/503 `{ error, code }` JSON bytes. +export type { IdentityFailure }; + +export interface AppConfig { + /** + * Serve the typed API under this path prefix ("/api"). make() builds the + * `router.prefixed(mountPrefix)` view internally; omit to serve at root. + */ + readonly mountPrefix?: `/${string}`; + /** + * How identity-resolution failures render. The facade builds `authenticate` + * from the `IdentityProvider` tag, so the failure channel is always the shared + * `IdentityFailure`: cloud renders its `{ error, code }` JSON, self-host 401/403 + * text. + */ + readonly failure: FailureRenderingStrategy; + /** + * Escape hatch for a platform-only export the runtime needs surfaced but the + * shared core never names (cloud's MCP session Durable Object class). make() + * passes it back out on the result untouched. + */ + readonly mcpExport?: McpExport; +} + +// --------------------------------------------------------------------------- +// make +// --------------------------------------------------------------------------- + +export interface ExecutorAppOptions< + TPlugins extends readonly AnyPlugin[], + RDb, + RAcct, + RStrategy, + RBoot, + RReq, + McpExport, + RIdentity = never, + RMcpAuth = never, +> { + /** The host's plugin tuple (drives the API + per-request extension Services). */ + readonly plugins: TPlugins; + /** The provider seams (variation points the core resolves). */ + readonly providers: AppProviders; + /** App-only surface the core never names (routes). */ + readonly extensions?: AppExtensions; + /** Mount prefix + failure strategy + the platform-only export escape hatch. */ + readonly config: AppConfig; + /** + * The boot-scoped Layer `provideMerge`'d under everything (the long-lived DB + * handle, the resolved identity, the router config). Satisfies the residual + * `RDb | RAcct` left by the seams. + */ + readonly boot: Layer.Layer; + /** Optional per-request Layer (cloud's request-scoped postgres socket). */ + readonly requestScoped?: Layer.Layer; +} + +export interface ExecutorApp { + /** + * The composed plugin `HttpApi` value. Reused by the host for Swagger/OpenAPI, + * `.prefix(...)` spec views, and clients — the SAME spec `make` mounts, so a + * host's Swagger extension never diverges from the served routes. + */ + readonly api: ReturnType>; + /** + * The fully-assembled, platform-agnostic app `Layer` (every route requirement + * provided). Typed loosely for the same reason `toApiHandler` is — each host's + * resolved channels differ — but at runtime every requirement is satisfied. + * The self-host Bun server (`serve.ts`) and cloud Workers both bind this shape. + */ + readonly appLayer: AppRouteLayer; + /** Bind `appLayer` to a `fetch`-style web handler (tests + Workers). */ + readonly toWebHandler: () => ApiHandler; + /** The platform-only export passed through from `config.mcpExport` (cloud's DO class). */ + readonly mcpExport: McpExport; +} + +/** + * Assemble the shared Executor HTTP app from a host's provider seams + + * extensions. Returns the platform-agnostic `appLayer` (the self-host Bun server + * + cloud Workers both bind this one shape), a `toWebHandler` binding (tests), + * and the pass-through `mcpExport`. + * + * Internally a faithful reproduction of every host's prior composition root: the + * execution-stack middleware wrapping the protected API, the MCP envelope's + * double-provide (build-time auth + per-request seams), the account API on the + * same prefixed router, the extension routes, and `provideMerge(boot)`. + */ +export const make = < + const TPlugins extends readonly AnyPlugin[], + RDb, + RAcct, + RStrategy, + RBoot, + RReq = never, + McpExport = undefined, + RIdentity = never, + RMcpAuth = never, +>( + options: ExecutorAppOptions< + TPlugins, + RDb, + RAcct, + RStrategy, + RBoot, + RReq, + McpExport, + RIdentity, + RMcpAuth + >, +): ExecutorApp => { + const { plugins, providers, config } = options; + + // The execution model is a discriminated union (see `AppProviders`): a host + // either supplies the SCOPED seams (db + plugins + engine -> a fresh + // per-(user, org) executor each request) or a single FIXED executor built once + // at boot (local). `fixedExecution` present on `providers` selects the latter. + const fixedExecution = providers.fixedExecution; + + // ---- a `mountPrefix`-prefixed view of the ambient router --------------- + // Providing it to the API builders makes every API/account route serve under + // the prefix (the router slices it before matching; no hand-written + // stripping). Omitted -> the ambient root router is used. + const prefix = config.mountPrefix; + const prefixedRouter = prefix + ? Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(prefix)), + ) + : undefined; + + // ---- (2) the ExecutionStackMiddleware --------------------------------- + // The identity seam authenticates; the failure strategy renders; the stack + // Layer + plugin tuple build the per-request executor. The facade ALWAYS builds + // the resolver by reading the neutral `IdentityProvider` tag — no host hand- + // writes one. Where the tag is satisfied is the only difference: self-host's + // identity layer is boot-scoped (in `boot`, captured below), cloud's reads a + // PER-REQUEST `UserStoreService`, so the facade folds the identity layer over + // `requestScoped` into this middleware (see `requestScopedIdentity` below) and + // the tag is resolved in the request fiber. Both fail the shared + // `Unauthorized | NoOrganization | Unavailable`. + const authenticate = ( + request: Request, + ): Effect.Effect => + Effect.flatMap(IdentityProvider.asEffect(), (provider) => provider.authenticate(request)); + + // The per-request layer combined into the middleware: cloud's `requestScoped` + // (the postgres socket) with `providers.identity` PROVIDE-MERGEd over it, so the + // identity layer is rebuilt per request in the same fiber scope as the socket it + // reads (Cloudflare Workers' I/O isolation) — `RIdentity` (cloud's + // `UserStoreService`) is satisfied by `requestScoped`, leaving the combined layer + // residual-free. Self-host omits `requestScoped` -> no per-request layer; its + // `IdentityProvider` (`RIdentity = never`) is boot-scoped in `boot`. + // The combined layer provides `IdentityProvider | RReq` and is residual-free in + // practice: a host that supplies `requestScoped` guarantees its `RReq` covers the + // identity layer's `RIdentity` (cloud's `RequestScopedServicesLive` provides the + // `UserStoreService`/`DbService` `workosIdentityLayer` reads). TS cannot reduce + // `Exclude` for abstract params, so widen to the complete shape. + const requestScopedIdentity = options.requestScoped + ? (providers.identity.pipe(Layer.provideMerge(options.requestScoped)) as Layer.Layer< + IdentityProvider | RReq + >) + : undefined; + + // The execution middleware, per model. SCOPED: build a fresh per-(user, org) + // executor each request from the resolved `Principal` over the stack seams. + // FIXED: resolve the `Principal` (-> `AuthContext`) but provide the single boot + // executor captured from `boot` (local). Both read the SAME `authenticate` + // resolver and failure strategy — only the executor lifetime differs. + // + // `RCapture` is the boot-scoped context captured ONCE at layer-build time. The + // per-request `IdentityProvider | RReq` is EXCLUDED so the resolver's identity + // layer + the stack's per-request deps stay residual, supplied per request by + // `requestScopedIdentity` folded into the middleware below. Self-host has no + // `requestScoped`, so its `IdentityProvider` + everything (`RDb | RStrategy`) is + // captured from `boot` — its prior behavior. + const executionMiddleware = fixedExecution + ? makeFixedExecutionMiddleware< + TPlugins, + IdentityFailure, + IdentityProvider, + RStrategy, + // Fixed mode has no `requestScoped`: the identity layer + the + // `FixedExecutionProvider` + the strategy are all boot-scoped (in `boot`), + // so the whole capture context flows there. + IdentityProvider | RStrategy | FixedExecutionProvider + >({ + plugins, + authenticate, + strategy: config.failure, + }) + : makeExecutionStackMiddleware< + TPlugins, + IdentityFailure, + IdentityProvider, + RDb, + RStrategy, + Exclude + >({ + plugins, + authenticate, + strategy: config.failure, + // db + plugins.provider + plugins.config + engine.codeExecutor + + // engine.decorator (default no-op). The merged Layer leaves the + // boot-scoped `RDb` residual, satisfied by `boot` below. + stackLayer: Layer.mergeAll( + providers.db, + providers.plugins.provider, + providers.plugins.config, + providers.engine.codeExecutor, + providers.engine.decorator ?? EngineDecoratorNoop, + ) as Layer.Layer< + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator, + never, + RDb + >, + }); + + // ---- (3) the protected (plugin) API, wrapped by the middleware --------- + const protectedApi = makeProtectedApiLayer(plugins, { + errorCapture: providers.errorCapture, + router: prefixedRouter, + }); + // The plugin handler Layers stay late-binding (each requires its plugin's + // `*ExtensionService` Tag), satisfied by the execution middleware here. + // Erased to the loose route-layer shape (matching `toApiHandler`): the + // assembled channels differ per host but every requirement is provided. + // + // `requestScopedIdentity` (cloud's per-request postgres socket + the identity + // layer rebuilt over it) is `.combine`'d INTO the execution middleware so it is + // rebuilt per HTTP request — `requestScopedMiddleware` runs `Layer.build` + // inside the per-request fiber's scope (Cloudflare Workers' I/O isolation forbids + // sharing a socket across requests). Combining drops the resolver's per-request + // `IdentityProvider` (resolved over the socket) and the stack's `DbService` from + // the middleware's `requires`. Self-host + local omit `requestScoped` -> the + // plain middleware `.layer`, whose residual `IdentityProvider` (and, for fixed, + // `FixedExecutionProvider`) flows to boot-scoped `boot`. + const middlewareLayer = ( + requestScopedIdentity + ? executionMiddleware.combine(requestScopedMiddleware(requestScopedIdentity)) + : executionMiddleware + ).layer as AppRouteLayer; + const pluginApiLive = protectedApi.layer.pipe(Layer.provide(middlewareLayer)) as AppRouteLayer; + + // ---- (5) the account API (optional) ----------------------------------- + // The account middleware provides `AccountProvider` per request; its residual + // `RAcct` (cloud's control-plane services; `never` for self-host) flows through + // to `boot`. Omit `providers.account` -> no account API (the test-stub path). + const apiLive: AppRouteLayer = providers.account + ? Layer.merge( + pluginApiLive, + makeAccountApiLayer( + providers.account as Layer.Layer, + prefixedRouter ? { router: prefixedRouter } : {}, + ) as AppRouteLayer, + ) + : pluginApiLive; + + // ---- (4) the MCP serving envelope (optional) -------------------------- + // The two providers, by design (mirrors makeSelfHostMcp): + // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's + // build-time `McpAuthProvider` requirement (it registers a GET per + // provider-declared discovery path). + // - `HttpRouter.provideRequest(McpSeams)` clears the route handlers' + // per-request `Requires` markers (auth + session store + reporter) so the + // /mcp routes carry no leftover requirements when merged into the router. + // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth = + // IdentityProvider` for self-host, whose MCP auth genuinely reads the fallback; + // `never` for cloud, whose MCP plane is a separate credential surface). The + // facade provides the identity seam to mcp.auth either way (a no-op when the + // seam ignores it). `RIdentity` (cloud's `UserStoreService`) is satisfied by + // `requestScoped`, so the MCP identity layer is a COMPLETE `Layer` + // even though cloud's MCP path never invokes it (the socket is never opened). + const mcpIdentity = ( + options.requestScoped + ? providers.identity.pipe(Layer.provide(options.requestScoped)) + : providers.identity + ) as Layer.Layer; + const mcpRouteLive = providers.mcp ? buildMcpRoutes(providers.mcp, mcpIdentity) : undefined; + + // ---- (6) extension routes (Better Auth handler, Swagger, …) ----------- + const extensionRoutes = options.extensions?.routes ?? []; + + // ---- (7) provideMerge(boot) -> the AppLayer --------------------------- + // `provideMerge(boot)` resolves the seams' residual requirements (the + // long-lived DB handle, the control-plane services); the runtime contract is + // the same — every route requirement is provided. + const routeLayers: AppRouteLayer[] = [apiLive]; + if (mcpRouteLive) routeLayers.push(mcpRouteLive); + for (const route of extensionRoutes) routeLayers.push(route); + + const merged = Layer.mergeAll(routeLayers[0], ...routeLayers.slice(1)); + + // `requestScoped` is NOT merged into `boot` — that would build the per-request + // socket ONCE at boot. It is folded into the execution-stack middleware (above) + // and into the account middleware + extension routes (the host self-combines + // those) so each rebuilds per request. `boot` is the long-lived context. + const appLayer: AppRouteLayer = merged.pipe(Layer.provideMerge(options.boot)); + + return { + api: protectedApi.api, + appLayer, + // `toApiHandler` takes the (covariant-on-output) loose `Layer`; our + // `AppRouteLayer` uses `never` in the contravariant output slot, so widen. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + toWebHandler: () => toApiHandler(appLayer as Layer.Layer), + mcpExport: config.mcpExport as McpExport, + }; +}; + +/** + * Compose the MCP serving routes over the auth/sessions/reporter seams. The auth + * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides + * the complete identity seam ONCE (memoized) and shares it across the build-time + * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single + * identity resolution serves both. When the auth seam ignores identity + * (`RMcpAuth = never`, cloud), the provide is a harmless no-op. + */ +const buildMcpRoutes = ( + mcp: McpProviders, + identity: Layer.Layer, +): Layer.Layer => { + // The auth seam may declare `IdentityProvider` as a requirement (self-host's + // genuinely reads it; cloud's ignores it — its MCP JWT/api-key path is separate). + // Either way the identity seam is provided ONCE (memoized) and shared across the + // build-time provide + the per-request `provideRequest`. The provided + // `IdentityProvider` covers `RMcpAuth` whether it is `IdentityProvider` or `never`. + const mcpAuthLive = (mcp.auth as Layer.Layer).pipe( + Layer.provide(identity), + ); + const mcpSeams = Layer.mergeAll(mcpAuthLive, mcp.sessions, mcp.reporter ?? McpErrorReporterNoop); + return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive)); +}; + +// Re-exported so a strategy author building the `config.failure` value can name +// the `Principal` the strategy renders failures around. +export type { Principal }; diff --git a/packages/core/api/src/server/executor-fuma-db.ts b/packages/core/api/src/server/executor-fuma-db.ts new file mode 100644 index 000000000..562b1fea4 --- /dev/null +++ b/packages/core/api/src/server/executor-fuma-db.ts @@ -0,0 +1,51 @@ +// --------------------------------------------------------------------------- +// The DbProvider seam — host-composition over the shared FumaDB assembly. +// +// `DbProvider` is the Effect seam P3's `makeScopedExecutor` reads the handle +// from. Each app provides a Layer wrapping its existing connection + +// schema-ensure strategy; the handle shape is uniform (`{ db, fuma, close }`) +// while the bring-up impl stays per-provider. +// +// The pure assembly (`createExecutorFumaDb` + its types) lives in the SDK +// because the SDK's own sqlite test backend shares it; it is re-exported from +// `@executor-js/api/server` so hosts import the assembly AND the seam from one +// host surface. This module owns only the host-composition seam. +// --------------------------------------------------------------------------- + +import { Context, Effect, Layer } from "effect"; + +import type { ExecutorDbHandle } from "@executor-js/sdk/host-internal"; + +// Re-export the pure FumaDB assembly + its types from the SDK so hosts get the +// whole DB surface from one place (`@executor-js/api/server`). +export { + createExecutorFumaDb, + type CreateExecutorFumaDbOptions, + type ExecutorDbHandle, + type ExecutorDbProvider, + type ExecutorFumaDb, + type ExecutorFumaSchema, +} from "@executor-js/sdk/host-internal"; + +/** + * The injection point for the executor's FumaDB handle. P3's + * `makeScopedExecutor` reads `db` from here. Each app supplies a Layer wrapping + * its existing driver-open + schema-ensure; the bring-up strategy stays + * per-provider. + */ +export class DbProvider extends Context.Service()( + "@executor-js/sdk/DbProvider", +) {} + +/** + * Build a scoped `DbProvider` Layer from an acquire that opens the host's + * driver and assembles the handle (typically by calling `createExecutorFumaDb` + * after its own driver-open + schema bring-up). The handle's `close` runs on + * scope teardown. + */ +export const dbProviderLayer = ( + acquire: Effect.Effect, +): Layer.Layer => + Layer.effect(DbProvider)( + Effect.acquireRelease(acquire, (handle) => Effect.promise(() => handle.close())), + ); diff --git a/packages/core/api/src/server/fixed-execution-middleware.ts b/packages/core/api/src/server/fixed-execution-middleware.ts new file mode 100644 index 000000000..a284c2d3e --- /dev/null +++ b/packages/core/api/src/server/fixed-execution-middleware.ts @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// Fixed-executor ExecutionStackMiddleware — the single-scope, boot-built +// execution variant of `./execution-stack-middleware.ts`. +// +// The per-request `ExecutionStackMiddleware` resolves a `Principal` and then +// builds a FRESH per-(user, org) executor each request via `makeExecutionStack` +// -> `makeScopedExecutor` -> `makeUserOrgScopeStack(accountId, organizationId, +// organizationName)`. That is the cloud / self-host model: a 2-level +// `[user-org:…, org]` scope stack derived from identity. +// +// Local is structurally different: ONE executor is built once at boot over a +// SINGLE scope derived from the working directory (`-`), with +// `oauthEndpointUrlPolicy: { allowHttp: true }`, and shared across every request +// (and the in-process MCP). There is no (user, org) and no per-request scope. +// Forcing local through the scope-stack middleware would (a) swap its cwd scope +// for a synthetic `user-org:` scope key — orphaning existing `~/.executor` data +// — and (b) silently drop `allowHttp`. +// +// So a host whose execution is a single boot executor supplies a +// `FixedExecutionProvider` (the pre-built executor + engine) and this middleware +// resolves identity to `AuthContext` exactly like the scoped variant, then +// provides the FIXED executor + engine + plugin extension Services to the +// handler — no per-request rebuild. The identity seam still runs (so a host can +// gate or attribute requests), but the executor is constant. This is local's +// genuine model expressed as a first-class `make()` execution mode, not a +// special case bolted onto the scoped path. +// --------------------------------------------------------------------------- + +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { Context, Effect } from "effect"; +import type * as Cause from "effect/Cause"; + +import type { AnyPlugin, Executor, PluginExtensions } from "@executor-js/sdk"; +import type { ExecutionEngine } from "@executor-js/execution"; + +import { ExecutionEngineService, ExecutorService } from "../services"; +import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; +import { authContextFromPrincipal, AuthContext, type Principal } from "./identity"; +import type { FailureRenderingStrategy } from "./execution-stack-middleware"; + +/** + * The pre-built, boot-scoped execution a fixed-executor host serves on. Local + * builds this ONCE (single cwd scope + `allowHttp`) and shares it across every + * request and the in-process MCP. `extensions` is the plugin extension map + * (`executor[pluginId]`) the handlers' `*ExtensionService` Tags read. + */ +export interface FixedExecution { + readonly executor: Executor; + readonly engine: ExecutionEngine; + readonly extensions: PluginExtensions; +} + +export class FixedExecutionProvider extends Context.Service< + FixedExecutionProvider, + FixedExecution +>()("@executor-js/api/FixedExecutionProvider") {} + +export interface MakeFixedExecutionMiddlewareOptions< + TPlugins extends readonly AnyPlugin[], + E, + RLong, + RStrategy, +> { + /** The host's plugin tuple — drives the typed extension Services and binding. */ + readonly plugins: TPlugins; + /** + * Resolve the inbound web `Request` to a neutral `Principal`. The credential + * shape stays inside this function; local's single-user provider always + * resolves the one local Principal. + */ + readonly authenticate: (request: Request) => Effect.Effect; + /** Render `authenticate` failures (text for local, matching self-host). */ + readonly strategy: FailureRenderingStrategy; +} + +/** + * Build the fixed-executor `ExecutionStackMiddleware`. Per request: resolve the + * `Principal` (and render any failure), build the `AuthContext`, then provide + * the boot-built `FixedExecutionProvider`'s executor + engine + plugin extension + * Services to the wrapped handler. `RCapture` is the boot-scoped context + * captured once at layer-build time (the identity provider + the fixed execution + * seam); the per-request body depends only on `HttpRouter`-provided context. + * + * Returned as the `HttpRouter.middleware` value (NOT `.layer`) so it composes + * the same way the scoped variant does. + */ +export const makeFixedExecutionMiddleware = < + const TPlugins extends readonly AnyPlugin[], + E, + RLong = never, + RStrategy = never, + RCapture = RLong | RStrategy | FixedExecutionProvider, +>( + options: MakeFixedExecutionMiddlewareOptions, +) => { + const provideExecutorExtensions = providePluginExtensions(options.plugins); + return HttpRouter.middleware<{ + provides: + | AuthContext + | ExecutorService + | ExecutionEngineService + | PluginExtensionServices; + }>()( + Effect.gen(function* () { + const captured = yield* Effect.context(); + const { executor, engine, extensions } = yield* FixedExecutionProvider.asEffect(); + return (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const webRequest = yield* HttpServerRequest.toWeb(request); + const resolved = yield* options.strategy.renderFailure(options.authenticate(webRequest)); + // The strategy recovered the failure into a Response — return it. + if (!isPrincipal(resolved)) return resolved; + const auth = AuthContext.of(authContextFromPrincipal(resolved)); + return yield* httpEffect.pipe( + Effect.provideService(AuthContext, auth), + Effect.provideService(ExecutorService, executor), + Effect.provideService(ExecutionEngineService, engine), + provideExecutorExtensions(extensions as PluginExtensions), + ); + }).pipe(Effect.provideContext(captured as Context.Context)); + }), + ); +}; + +// `renderFailure` yields either the resolved `Principal` (proceed) or an +// already-built `HttpServerResponse` (the strategy recovered the failure). +const isPrincipal = ( + value: Principal | HttpServerResponse.HttpServerResponse, +): value is Principal => !HttpServerResponse.isHttpServerResponse(value); diff --git a/packages/core/api/src/server/host-foundation.ts b/packages/core/api/src/server/host-foundation.ts new file mode 100644 index 000000000..1b22b70c4 --- /dev/null +++ b/packages/core/api/src/server/host-foundation.ts @@ -0,0 +1,223 @@ +// --------------------------------------------------------------------------- +// Shared host-boot API foundation. +// +// Every product host (cloud, self-host, local) assembles the same protected +// API the same way: +// +// composePluginApi(plugins) +// -> observabilityMiddleware(api) (defect safety net) +// -> HttpApiBuilder.layer(api) (the routes) +// + CoreHandlers + composePluginHandlerLayer(plugins) +// + ErrorCapture (Sentry / console / in-memory) +// + RouterConfigLive (maxParamLength bump) +// +// They differ only in three knobs: +// - `errorCapture` — the host's `ErrorCapture` impl (Sentry vs console). +// - `router` — an optional prefixed `HttpRouter` view (self-host +// serves under `/api`; cloud/local serve at root). +// - the plugin set — typed straight off the passed tuple. +// +// The account API mounts the same provider-neutral `AccountHandlers` behind a +// per-request `AccountProvider`, again differing only by the optional router and +// the service-providing layer. +// +// `toApiHandler` is the `HttpRouter.toWebHandler(appLayer + platform)` boiler- +// plate that every web-handler binding repeats: build, expose `{ handler, +// dispose }`. The per-host listening adapters (TanStack Start request +// middleware, the Bun socket) stay app-specific. +// +// NOTE: this module intentionally imports nothing host-specific (no +// `cloudflare:workers`, no Bun platform), so it stays importable from the +// Workers test runtime and from every host. +// --------------------------------------------------------------------------- + +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { Layer } from "effect"; +import type { AnyPlugin } from "@executor-js/sdk"; + +import { observabilityMiddleware, type ErrorCapture } from "../observability"; +import { AccountHttpApi } from "../account/api"; +import { AccountHandlers } from "../account/handlers"; +import type { AccountProvider } from "../account/service"; +import { composePluginApi, composePluginHandlerLayer } from "../plugin-routes"; +import { CoreHandlers } from "../handlers"; +import { requestScopedMiddleware } from "./request-scoped"; +import { RouterConfigLive } from "./router-config"; + +// `HttpApiBuilder.layer` requires `HttpRouter.HttpRouter`; a host that serves +// the API under a path prefix passes a `router.prefixed("/api")` view as this +// layer so every route carries the prefix. Hosts serving at root omit it (the +// ambient default `HttpRouter` is used). The prefixed view DERIVES from the +// ambient router, so it both provides and requires `HttpRouter.HttpRouter` +// (self-host's `PrefixedRouterLive`). Keeping the requirement channel precise +// (not `any`) avoids leaking `any` into every assembled host layer. +type RouterLayer = Layer.Layer; + +// --------------------------------------------------------------------------- +// Protected (plugin) API +// --------------------------------------------------------------------------- + +export interface MakeProtectedApiLayerOptions { + /** + * The host's `ErrorCapture` implementation. Provided ABOVE the handler + + * middleware layers so both the `capture(...)` typed-channel translation + * (`StorageError -> InternalError(traceId)`) AND the observability + * middleware's defect catchall resolve the same backend. + */ + readonly errorCapture: Layer.Layer; + /** + * Optional prefixed `HttpRouter` view (e.g. `router.prefixed("/api")`). When + * present every API route serves under that prefix. Omit to serve at root. + */ + readonly router?: RouterLayer; +} + +/** + * Assemble the protected (plugin) API into its boot Layer. + * + * Wires, in order: `composePluginApi(plugins)` -> + * `observabilityMiddleware(api)` -> `HttpApiBuilder.layer(api)` provided with + * `CoreHandlers` + `composePluginHandlerLayer(plugins)` + the host's + * `ErrorCapture` + `RouterConfigLive` (+ the optional prefixed router). + * + * Returns `{ api, handlers, layer }` because hosts consume all three + * independently: + * - `api` — the composed `HttpApi` value, reused for Swagger/OpenAPI, + * `.prefix(...)` spec views, `HttpApiClient.ForApi`, and + * `.add(...)` of host-only groups (cloud docs). + * - `handlers` — `CoreHandlers` + every plugin's late-binding `handlers()` + * Layer; reused by test harnesses building against a fake + * middleware. + * - `layer` — the wired boot Layer. The plugin handler Layers stay + * late-binding (they require each plugin's `*ExtensionService` + * Tag), so the host provides its per-request execution-stack + * middleware on this `layer` itself. + */ +export const makeProtectedApiLayer = ( + plugins: TPlugins, + options: MakeProtectedApiLayerOptions, +) => { + const api = composePluginApi(plugins); + const handlers = Layer.mergeAll(CoreHandlers, composePluginHandlerLayer(plugins)); + + // `RouterConfigLive` is folded in here so every host gets the raised + // `maxParamLength` without re-wiring it; the optional prefixed router is + // merged alongside it so `HttpApiBuilder.layer`'s `HttpRouter` requirement is + // satisfied by the prefixed view when the host wants a path namespace. + const routerSupport = options.router + ? Layer.merge(RouterConfigLive, options.router) + : RouterConfigLive; + + const layer = HttpApiBuilder.layer(api).pipe( + Layer.provide(Layer.mergeAll(handlers, observabilityMiddleware(api))), + Layer.provide(options.errorCapture), + Layer.provide(routerSupport), + ); + + return { api, handlers, layer }; +}; + +// --------------------------------------------------------------------------- +// Account API +// --------------------------------------------------------------------------- + +export interface MakeAccountApiLayerOptions { + /** + * Optional prefixed `HttpRouter` view, matching the protected API's prefix so + * the account routes register on the same `/api`-prefixed router. + */ + readonly router?: RouterLayer; +} + +/** + * Mount the shared, provider-neutral `AccountHandlers` (me / API keys / org) + * behind a per-request `AccountProvider`: + * + * HttpApiBuilder.layer(AccountHttpApi) + * -> AccountHandlers + * -> the `AccountProvider`-providing middleware + * -> (optional) prefixed router + * + * `accountProviderMiddleware` is the router-middleware Layer that provides + * `AccountProvider` per request — `requestScopedMiddleware(accountProviderLayer) + * .layer` for the self-contained case (self-host's Better Auth service), or a + * bespoke middleware combined with `requestScopedMiddleware` (cloud builds the + * WorkOS service INSIDE the request body so it closes over the per-request + * postgres socket). Going through a router middleware means the handler's + * `AccountProvider` requirement is satisfied per-request WITHOUT leaking into the + * app layer's output requirements (a plain `Layer.provide` on the builder layer + * would leak it and break the host build). + * + * The middleware's three channels are generic (`MOut`/`ME`/`MR`) so the + * provided `Request.From<"Requires", AccountProvider>` marker AND each host's + * remaining requirements (cloud's long-lived control-plane + billing services, + * self-host's `never`) flow through precisely — a non-generic + * `Layer` parameter would widen the requirement channel to `any` + * and break the host build's leftover-requirement tracking. + * + * Use `accountProviderMiddlewareLayer(accountProviderLayer)` for the common case. + */ +export const makeAccountApiLayer = ( + accountProviderMiddleware: Layer.Layer, + options: MakeAccountApiLayerOptions = {}, +) => { + const base = HttpApiBuilder.layer(AccountHttpApi).pipe( + Layer.provide(AccountHandlers), + Layer.provide(accountProviderMiddleware), + ); + return options.router ? base.pipe(Layer.provide(options.router)) : base; +}; + +/** + * The common-case `AccountProvider` middleware: wrap a self-contained + * `Layer` in `requestScopedMiddleware` and take its `.layer`. + * (Hosts whose service must be built inside the request body — cloud — combine + * their own middleware with `requestScopedMiddleware` and pass that instead.) + */ +export const accountProviderMiddlewareLayer = ( + accountProviderLayer: Layer.Layer, +) => requestScopedMiddleware(accountProviderLayer).layer; + +// --------------------------------------------------------------------------- +// App-layer web-handler binding +// --------------------------------------------------------------------------- + +export interface ApiHandler { + readonly handler: (request: Request) => Promise; + readonly dispose: () => Promise; +} + +/** + * Bind a fully-assembled app `Layer` to a `fetch`-style web handler. + * + * This is the `HttpRouter.toWebHandler(appLayer + HttpServer.layerServices)` + * boilerplate every web-handler binding repeats: the web-handler binding + * supplies the HTTP platform services itself (no listening socket), then + * exposes `{ handler, dispose }`. Hosts that bind to a listening socket + * (self-host's Bun server) keep their own platform layer and DON'T use this. + * + * `appLayer` must already provide every `HttpRouter`/route requirement; this + * only adds `HttpServer.layerServices` so `toWebHandler` can run handlers off a + * synthetic platform. + */ +export const toApiHandler = ( + // The app layer must already provide every route requirement; only the HTTP + // platform is missing, which `HttpServer.layerServices` supplies below. Typed + // loosely (success/error/requirement channels erased) because each host's app + // layer has a different, fully-resolved set — self-host's `AppLayer` outputs + // `never`, local's outputs `ExecutorService | …` (provideMerge keeps them in + // the success channel). The runtime contract is the same either way. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + appLayer: Layer.Layer, +): ApiHandler => { + // `HttpServer.layerServices` supplies the synthetic HTTP platform so + // `toWebHandler` can run handlers without a listening socket. + const web = HttpRouter.toWebHandler(appLayer.pipe(Layer.provideMerge(HttpServer.layerServices))); + // With every requirement provided the leftover `HR` is `never`, so `handler` + // is the one-arg `(request) => Promise` form — but the loose + // `R = any` input widens `HR` to `any` (a two-arg signature), so narrow back + // to the runtime contract. + const handler = web.handler as (request: Request) => Promise; + return { handler, dispose: web.dispose }; +}; diff --git a/packages/core/api/src/server/identity.ts b/packages/core/api/src/server/identity.ts new file mode 100644 index 000000000..15da8ac12 --- /dev/null +++ b/packages/core/api/src/server/identity.ts @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// Provider-neutral identity seam — the ONE auth surface the executor API runs +// on. Cloud (WorkOS api-key + sealed-session) and self-host (Better Auth) each +// supply an `IdentityProvider` Layer; the shared `ExecutionStackMiddleware` +// (see `./execution-stack-middleware.ts`) consumes only this tag, never a +// provider's native session shape. Handlers depend only on `AuthContext`. +// +// Single source of truth promoted out of the two apps: +// - `Principal` — the neutral resolved identity (self-host's shape is +// the model: org name + roles; cloud passes `roles: []` +// and an empty email on the api-key path). +// - `AuthContext` — the one Context.Service handlers read (carries roles; +// cloud's old tag lacked them, forward-compatible). +// - `Unauthorized` / — the shared error set (httpApiStatus 401 / 403 / 503), +// `NoOrganization` / shared by every consumer in both apps. Self-host only +// `Unavailable` ever produces the first two; cloud also produces +// `Unavailable` (503) when api-key validation is down. +// - `IdentityProvider` — the swap seam: `authenticate(request) => +// Effect`. +// --------------------------------------------------------------------------- + +import { Context, Effect, Schema } from "effect"; + +/** + * The provider-neutral resolved identity. Both self-host's AuthProvider impls + * (single-admin, Better Auth) and cloud's WorkOS path produce this. Self-host's + * original `Principal` is the model — it carries `organizationName` (cloud's + * resolver already yielded it) AND `roles` (cloud supplies `[]`). + */ +export interface Principal { + readonly accountId: string; + readonly organizationId: string; + readonly organizationName: string; + readonly email: string; + readonly name: string | null; + readonly avatarUrl: string | null; + readonly roles: readonly string[]; +} + +/** + * The single `AuthContext` every executor-API handler reads. The roles-bearing + * tag from self-host is the model; cloud now provides `roles: []` on it, which + * is forward-compatible (cloud handlers never read roles today). + */ +export class AuthContext extends Context.Service< + AuthContext, + { + readonly accountId: string; + readonly organizationId: string; + readonly email: string; + readonly name: string | null; + readonly avatarUrl: string | null; + readonly roles: readonly string[]; + } +>()("@executor-js/api/AuthContext") {} + +/** Build the shared `AuthContext` value from a resolved `Principal`. */ +export const authContextFromPrincipal = (principal: Principal): AuthContext["Service"] => ({ + accountId: principal.accountId, + organizationId: principal.organizationId, + email: principal.email, + name: principal.name, + avatarUrl: principal.avatarUrl, + roles: principal.roles, +}); + +// Optional per-failure render hints. Self-host produces the bare error (these +// stay `undefined`) and its text strategy renders a generic body. Cloud fills +// `code` + `message` so its failure strategy can reproduce the exact +// `{ error, code }` JSON bytes its old `HttpResponseError` paths emitted. The +// status is fixed by the tag (401 / 403 / 503), so it is not carried as a field. +const renderHints = { + /** Machine-readable failure code (cloud's `{ code }` body field). */ + code: Schema.optional(Schema.String), + /** Human-readable message (cloud's `{ error }` body field). */ + message: Schema.optional(Schema.String), +} as const; + +/** Authenticated but not authorized — no valid credential. Renders 401. */ +export class Unauthorized extends Schema.TaggedErrorClass()( + "Unauthorized", + renderHints, + { httpApiStatus: 401 }, +) {} + +/** Valid credential, but the principal belongs to no organization. Renders 403. */ +export class NoOrganization extends Schema.TaggedErrorClass()( + "NoOrganization", + renderHints, + { httpApiStatus: 403 }, +) {} + +/** + * The credential could not be validated for a transient reason (cloud's api-key + * validation backend is down). Renders 503 — the caller should retry. Self-host + * never produces this; it is part of the shared set so cloud provides the SAME + * neutral `IdentityProvider` tag without a wider error channel. + */ +export class Unavailable extends Schema.TaggedErrorClass()( + "Unavailable", + renderHints, + { httpApiStatus: 503 }, +) {} + +/** + * The swap seam. Resolves an incoming request to a `Principal`. WorkOS (cloud) + * and Better Auth (self-host) are interchangeable implementations; nothing + * downstream knows which is wired. + * + * - succeeds with a `Principal` -> authenticated + * - fails `Unauthorized` -> no/invalid credential (renders 401) + * - fails `NoOrganization` -> valid credential, no org (renders 403) + * - fails `Unavailable` -> transient validation outage (renders 503; + * cloud only — self-host never produces it) + * + * Adapter-specific credential precedence (cloud's Bearer-api-key-beats-sealed- + * session, self-host's cookie/bearer/x-api-key cascade) stays INSIDE each impl. + * Adapter infra defects (cloud's WorkOS / user-store failures) are `Effect.die`d + * INSIDE the impl so they surface as 500 defects, never as this error channel. + */ +export type IdentityFailure = Unauthorized | NoOrganization | Unavailable; + +export interface IdentityProviderShape { + readonly authenticate: (request: Request) => Effect.Effect; +} + +export class IdentityProvider extends Context.Service()( + "@executor-js/api/IdentityProvider", +) {} diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts new file mode 100644 index 000000000..216413102 --- /dev/null +++ b/packages/core/api/src/server/mcp-build.ts @@ -0,0 +1,67 @@ +import { Effect, Layer } from "effect"; + +import { McpErrorReporter, type Principal } from "@executor-js/host-mcp"; +import { + McpEngineBuildError, + type McpBuildServer, +} from "@executor-js/host-mcp/in-memory-session-store"; +import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; + +import { ErrorCapture } from "../observability"; +import { CodeExecutorProvider, EngineDecorator, makeExecutionStack } from "./execution-stack"; +import { DbProvider } from "./executor-fuma-db"; +import { HostConfig, PluginsProvider } from "./scoped-executor"; + +// --------------------------------------------------------------------------- +// Shared in-process MCP host helpers. +// +// Every host that serves MCP from one isolate (self-host, the Cloudflare QuickJS +// host) builds its per-session McpServer the same way — assemble the scoped +// engine via `makeExecutionStack`, wrap it with `createExecutorMcpServer` — and +// reports orchestration defects through the same console `ErrorCapture` seam. +// These two factories are the single home for that logic; a host supplies ONLY +// its fully-provided execution-stack layer and its `ErrorCapture` layer. The +// cross-isolate variant (cloud's Durable Object store) is the exception that +// builds its engine inside the DO. +// --------------------------------------------------------------------------- + +/** The five execution-stack seams a host fully provides (no residual). */ +export type McpExecutionStackLayer = Layer.Layer< + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator +>; + +/** + * Build the per-session MCP server factory over a host's execution stack: + * `makeExecutionStack` → engine → `createExecutorMcpServer`. Hosts differ only + * in the injected stack layer (libSQL vs D1, etc.). + */ +export const makeMcpBuildServer = + (executionStack: McpExecutionStackLayer): McpBuildServer => + (principal: Principal) => + makeExecutionStack( + principal.accountId, + principal.organizationId, + principal.organizationName, + ).pipe( + Effect.map(({ engine }) => engine), + Effect.provide(executionStack), + Effect.mapError((cause) => new McpEngineBuildError({ cause })), + Effect.flatMap((engine) => createExecutorMcpServer({ engine })), + ); + +/** + * The standard console `McpErrorReporter` seam: route an orchestration defect + * the MCP envelope would otherwise swallow into a 500 through the host's + * `ErrorCapture`, so operators still see it. Hosts differ only in the capture + * layer (self-host/Cloudflare console; cloud overrides with Sentry separately). + */ +export const makeConsoleMcpErrorReporter = ( + errorCapture: Layer.Layer, +): Layer.Layer => + Layer.effect( + McpErrorReporter, + Effect.gen(function* () { + const capture = yield* ErrorCapture; + return { report: (cause) => Effect.asVoid(capture.captureException(cause)) }; + }), + ).pipe(Layer.provide(errorCapture)); diff --git a/apps/cloud/src/api/request-scoped.ts b/packages/core/api/src/server/request-scoped.ts similarity index 100% rename from apps/cloud/src/api/request-scoped.ts rename to packages/core/api/src/server/request-scoped.ts diff --git a/packages/core/api/src/server/router-config.ts b/packages/core/api/src/server/router-config.ts new file mode 100644 index 000000000..834eb92d9 --- /dev/null +++ b/packages/core/api/src/server/router-config.ts @@ -0,0 +1,11 @@ +import { HttpRouter } from "effect/unstable/http"; +import { Layer } from "effect"; + +// --------------------------------------------------------------------------- +// Shared `HttpRouter.RouterConfig`. Raises `maxParamLength` past the default +// so long path params (scope ids, execution ids, etc.) match instead of being +// truncated at the router's default limit. Every host serves the same routes, +// so they all use this single config. +// --------------------------------------------------------------------------- + +export const RouterConfigLive = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts new file mode 100644 index 000000000..5440a5368 --- /dev/null +++ b/packages/core/api/src/server/scoped-executor.ts @@ -0,0 +1,165 @@ +// --------------------------------------------------------------------------- +// Shared scoped-executor factory + the host seams it reads from. +// +// Cloud and self-host historically hand-rolled an identical `createScopedExecutor`: +// read the DB handle from a host service, build fresh per-request plugins, build a +// hosted HTTP client, build the `[userOrgScope, orgScope]` scope stack (P1), and +// call `createExecutor({...})` with a byte-identical option shape. The ONLY real +// differences were the DB source/lifetime, the plugin instances, and two host +// config scalars (`allowLocalNetwork`, `webBaseUrl`). +// +// `makeScopedExecutor` owns that common body. The per-host knobs are injected +// through three Effect seams: +// - `DbProvider` (P2a, executor-fuma-db.ts) — the `{ db }` handle. Cloud's +// Layer rebuilds the postgres-js fuma client per request off the +// request-scoped `DbService`; self-host's Layer projects its long-lived +// handle. `makeScopedExecutor` just reads `db` — it never caches a handle, +// so both lifetimes are preserved by the Layer the host supplies. +// - `PluginsProvider` — the plugin array. Cloud injects per-request WorkOS +// credentials; self-host returns the plain plugin list. +// - `HostConfig` — `allowLocalNetwork` (drives the hosted HTTP client guard) +// and `webBaseUrl` (the core-tools elicitation base URL). +// +// This is host-composition machinery: it lives in `@executor-js/api/server` +// (the host surface), not in `@executor-js/sdk` (the plugin-author contract). +// `createExecutor`/`Executor` and the `makeUserOrgScopeStack` scope-id contract +// stay in the SDK and are imported from there. +// --------------------------------------------------------------------------- + +import { Context, Effect, Option } from "effect"; + +import { + createExecutor, + makeUserOrgScopeStack, + type AnyPlugin, + type Executor, + type StorageFailure, +} from "@executor-js/sdk"; +import { makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal"; + +import { DbProvider } from "./executor-fuma-db"; + +// --------------------------------------------------------------------------- +// HostConfig seam — the two host scalars that vary the `createExecutor` options. +// --------------------------------------------------------------------------- + +export interface HostConfigShape { + /** + * Whether the hosted HTTP client may dial private/loopback addresses. Each + * host reads it from config (`EXECUTOR_ALLOW_LOCAL_NETWORK` / `ALLOW_LOCAL_NETWORK`); + * production hosts leave it off. Drives `makeHostedHttpClientLayer`. + */ + readonly allowLocalNetwork: boolean; + /** + * Base URL of the executor's web UI. Threaded into `coreTools.webBaseUrl` so + * `secrets.create` can point the user at `${webBaseUrl}/secrets?...`. + * + * Optional: when a host can't know its public URL at boot (a Worker has no + * static URL var), leave it unset and `makeScopedExecutor` falls back to the + * current request's origin (`RequestWebOrigin`). An explicit value always wins. + */ + readonly webBaseUrl?: string; +} + +export class HostConfig extends Context.Service()( + "@executor-js/sdk/HostConfig", +) {} + +// --------------------------------------------------------------------------- +// RequestWebOrigin seam — the public origin of the in-flight request +// (`https://host[:port]`), used to derive `webBaseUrl` when no explicit one is +// configured. Provided per request by the host's request pipeline (the shared +// `makeExecutionStackMiddleware` for the HTTP API; the session DO for MCP). +// Read OPTIONALLY via `Effect.serviceOption`, so it never enters +// `makeScopedExecutor`'s `R` channel — non-request callers (CLI, tests) simply +// fall through to the configured value. +// --------------------------------------------------------------------------- + +export interface RequestWebOriginShape { + readonly origin: string; +} + +export class RequestWebOrigin extends Context.Service()( + "@executor-js/api/RequestWebOrigin", +) {} + +// --------------------------------------------------------------------------- +// PluginsProvider seam — the per-host (and possibly per-request) plugin array. +// +// Returns an Effect so a host that needs request-scoped credentials (cloud reads +// WorkOS creds from the Worker env) can build fresh plugin instances each call, +// while a host with static plugins (self-host) just returns a constant array. +// --------------------------------------------------------------------------- + +export interface PluginsProviderShape { + readonly plugins: () => readonly AnyPlugin[]; +} + +export class PluginsProvider extends Context.Service()( + "@executor-js/sdk/PluginsProvider", +) {} + +// --------------------------------------------------------------------------- +// makeScopedExecutor — the shared per-(user, org) executor body. +// +// Scope stack is `[userOrgScope, orgScope]` (innermost first) from +// `makeUserOrgScopeStack` (P1): the user-within-org scope id bakes in the org id +// so the same user in a different org gets a distinct scope row; OAuth token +// writes target the inner scope, org-wide credentials the outer. +// +// The `createExecutor` option shape below is byte-identical to the bodies it +// replaces: `{ scopes, db, plugins, httpClientLayer, onElicitation: "accept-all", +// coreTools: { webBaseUrl } }`. +// +// `TPlugins` is a caller-supplied phantom: the `PluginsProvider` seam returns an +// erased `AnyPlugin[]` (a Context value can't carry the tuple type), so the host +// names its plugin tuple (`makeScopedExecutor(...)`) to recover +// the `Executor` shape with the plugin extension namespaces +// (`.openapi`, `.graphql`, …) that `providePluginExtensions` and callers read. +// The default keeps the un-narrowed `Executor` for hosts that don't care. +// --------------------------------------------------------------------------- + +export const makeScopedExecutor = < + const TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[], +>( + accountId: string, + organizationId: string, + organizationName: string, +): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => + Effect.gen(function* () { + const { db } = yield* DbProvider; + const { plugins: pluginsFactory } = yield* PluginsProvider; + const config = yield* HostConfig; + // Explicit config wins; otherwise fall back to the request origin if a host + // provided one (HTTP middleware / MCP session DO). Stays `undefined` for + // non-request callers — `coreTools.webBaseUrl` is optional and only the + // browser-handoff tools require it (they fail clearly if it's truly absent). + const requestOrigin = yield* Effect.serviceOption(RequestWebOrigin); + const webBaseUrl = + config.webBaseUrl ?? + Option.match(requestOrigin, { onNone: () => undefined, onSome: (o) => o.origin }); + + const plugins = pluginsFactory(); + const httpClientLayer = makeHostedHttpClientLayer({ + allowLocalNetwork: config.allowLocalNetwork, + }); + + // The account id is the first segment of the persisted `user-org:` scope key + // (its namespace name is the contract; `makeUserOrgScopeStack` keeps it). + const scopes = makeUserOrgScopeStack(accountId, organizationId, organizationName); + + const executor = yield* createExecutor({ + scopes, + db, + plugins, + httpClientLayer, + onElicitation: "accept-all", + coreTools: { + webBaseUrl, + }, + }); + // The seam erases the plugin tuple type; the caller re-narrows via the + // `TPlugins` phantom. Runtime shape is identical to a typed + // `createExecutor({ plugins })` call. + return executor as Executor; + }); diff --git a/packages/core/cli/src/commands/schema.ts b/packages/core/cli/src/commands/schema.ts index fdc15a2c8..261f7139e 100644 --- a/packages/core/cli/src/commands/schema.ts +++ b/packages/core/cli/src/commands/schema.ts @@ -3,11 +3,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { Command } from "commander"; import { collectTables } from "@executor-js/sdk/core"; -import { getConfig } from "../utils/get-config.js"; + +// The executor's table set is fixed and plugin-independent (`collectTables()`), +// so schema generation needs no `executor.config.ts` — only the target ORM +// namespace/adapter/provider. The same tables render per database via flags. type SchemaGenerateOptions = { readonly cwd: string; - readonly config?: string; readonly output?: string; readonly namespace: string; readonly adapter: string; @@ -22,14 +24,6 @@ const schemaGenerateAction = async (opts: SchemaGenerateOptions) => { process.exit(1); } - const config = await getConfig({ cwd, configPath: opts.config }); - if (!config) { - console.error( - "No configuration file found. Add an `executor.config.ts` file to " + - "your project or pass the path using the `--config` flag.", - ); - process.exit(1); - } if (opts.adapter !== "drizzle") { console.error(`Unsupported schema adapter "${opts.adapter}". Supported adapters: drizzle.`); process.exit(1); @@ -49,7 +43,7 @@ const schemaGenerateAction = async (opts: SchemaGenerateOptions) => { const schema = fumaSchema({ version: opts.version, - tables: collectTables(config.plugins()), + tables: collectTables(), }); const factory = fumadb({ namespace: opts.namespace, @@ -75,9 +69,8 @@ export const schema = new Command("schema") .description("Database schema utilities") .addCommand( new Command("generate") - .description("Generate an ORM schema file from the executor config") + .description("Generate the ORM schema file for the executor's fixed table set") .option("-c, --cwd ", "the working directory", process.cwd()) - .option("--config ", "path to the executor config file") .option("--output ", "output file path for the generated schema") .option("--namespace ", "FumaDB namespace", "executor") .option("--adapter ", "FumaDB adapter", "drizzle") diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index edf390682..3853a6c2d 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -3,8 +3,8 @@ import * as Cause from "effect/Cause"; import type { Executor, ToolId, - Tool, - ToolSchema, + ToolView, + ToolSchemaView, InvokeOptions, Source, } from "@executor-js/sdk/core"; @@ -297,7 +297,7 @@ const paginate = (all: readonly T[], offset: number, limit: number): PagedRes }; }; -type SearchableTool = Pick; +type SearchableTool = Pick; type PreparedField = { readonly raw: string; @@ -513,8 +513,8 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( ), ); const ranked = all - .filter((tool: Tool) => matchesNamespace(tool, options?.namespace)) - .map((tool: Tool) => scoreToolMatch(tool, query)) + .filter((tool: ToolView) => matchesNamespace(tool, options?.namespace)) + .map((tool: ToolView) => scoreToolMatch(tool, query)) .filter(Predicate.isNotNull) .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); @@ -617,7 +617,7 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( // Single tools.schema() call — it already fetches the tool row // internally. No need to also call tools.list() just for name/description. - const schema: ToolSchema | null = yield* executor.tools.schema(path); + const schema: ToolSchemaView | null = yield* executor.tools.schema(path); // tools.schema() returns null if the tool doesn't exist. Fall back to // a minimal stub so callers can still render something. diff --git a/packages/core/fumadb/src/adapters/drizzle/index.ts b/packages/core/fumadb/src/adapters/drizzle/index.ts index 3a096793b..a87ff3f51 100644 --- a/packages/core/fumadb/src/adapters/drizzle/index.ts +++ b/packages/core/fumadb/src/adapters/drizzle/index.ts @@ -24,16 +24,38 @@ export interface DrizzleConfig { */ db: unknown; provider: Exclude; + /** + * Whether the underlying engine supports interactive transactions + * (BEGIN/COMMIT or the driver's `.transaction()`). Defaults to `true`. + * Set `false` for Cloudflare D1, which rejects interactive transactions — + * the adapter then runs transaction callbacks directly (auto-commit per + * statement, no atomic rollback). + */ + interactiveTransactions?: boolean; + /** + * Maximum bound parameters per query the engine accepts. When set, multi-row + * `createMany` inserts are batched so `rows * columns` stays within it. + * Cloudflare D1 caps this at 100; libSQL/Postgres leave it unset (no tight + * cap), keeping the row-count batch. + */ + maxBoundParameters?: number; } export function drizzleAdapter(options: DrizzleConfig): FumaDBAdapter { const settingsTableName = (namespace: string) => `private_${namespace}_settings`; + const interactiveTransactions = options.interactiveTransactions ?? true; return { name: "drizzle", createORM(schema) { - return fromDrizzle(schema, options.db, options.provider); + return fromDrizzle( + schema, + options.db, + options.provider, + interactiveTransactions, + options.maxBoundParameters + ); }, // assume the database is sync with Drizzle schema async getSchemaVersion() { diff --git a/packages/core/fumadb/src/adapters/drizzle/query.ts b/packages/core/fumadb/src/adapters/drizzle/query.ts index 34abb3b95..9471ecda0 100644 --- a/packages/core/fumadb/src/adapters/drizzle/query.ts +++ b/packages/core/fumadb/src/adapters/drizzle/query.ts @@ -169,7 +169,9 @@ function mapQueryResult(table: AnyTable, result: Record) { export function fromDrizzle( schema: AnySchema, _db: unknown, - provider: SQLProvider + provider: SQLProvider, + interactiveTransactions: boolean = true, + maxBoundParameters?: number ): AbstractQuery { const [db, drizzleTables] = parseDrizzle(_db); @@ -354,9 +356,18 @@ export function fromDrizzle( const idField = table.getIdColumn().names.drizzle; const drizzleTable = toDrizzle(table); values = values.map((v) => mapValues(v, table)); + // A multi-row insert binds (rows * columns) parameters in one statement. + // Some engines cap bound parameters per query (Cloudflare D1: 100), so + // size the batch by PARAMETER count, not row count — otherwise a wide + // table (e.g. tools) overflows with "too many SQL variables". Engines + // without a tight cap keep the row-count batch. + const columnsPerRow = values.length > 0 ? Math.max(1, Object.keys(values[0]!).length) : 1; + const batchSize = maxBoundParameters + ? Math.max(1, Math.min(CREATE_MANY_BATCH_SIZE, Math.floor(maxBoundParameters / columnsPerRow))) + : CREATE_MANY_BATCH_SIZE; const batches: (typeof values)[] = []; - for (let i = 0; i < values.length; i += CREATE_MANY_BATCH_SIZE) { - batches.push(values.slice(i, i + CREATE_MANY_BATCH_SIZE)); + for (let i = 0; i < values.length; i += batchSize) { + batches.push(values.slice(i, i + batchSize)); } if (provider === "sqlite" || provider === "postgresql") { @@ -392,10 +403,19 @@ export function fromDrizzle( await query; }, async transaction(run) { + // Some SQLite-compatible engines (Cloudflare D1) reject interactive + // transactions — both raw BEGIN/COMMIT and the driver's `.transaction()`. + // When disabled, run the operations directly against the same connection: + // each statement auto-commits, so there is no atomic rollback (the + // engine's constraint, not ours). libSQL/Postgres keep real transactions. + if (!interactiveTransactions) { + return run(fromDrizzle(schema, _db, provider, interactiveTransactions, maxBoundParameters)); + } + if (provider === "sqlite") { await executeRaw("BEGIN"); try { - const result = await run(fromDrizzle(schema, _db, provider)); + const result = await run(fromDrizzle(schema, _db, provider, interactiveTransactions, maxBoundParameters)); await executeRaw("COMMIT"); return result; } catch (e) { @@ -404,7 +424,9 @@ export function fromDrizzle( } } - return db.transaction((tx) => run(fromDrizzle(schema, tx, provider))); + return db.transaction((tx) => + run(fromDrizzle(schema, tx, provider, interactiveTransactions, maxBoundParameters)) + ); }, }); } diff --git a/packages/core/fumadb/src/adapters/prisma/query.ts b/packages/core/fumadb/src/adapters/prisma/query.ts index fccc469e4..a05539407 100644 --- a/packages/core/fumadb/src/adapters/prisma/query.ts +++ b/packages/core/fumadb/src/adapters/prisma/query.ts @@ -72,7 +72,7 @@ function buildWhere(condition: Condition): object { if (condition.type === ConditionType.Not) { return { - NOT: condition, + NOT: buildWhere(condition.item), }; } diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index ef74a89d3..05f3f94c6 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -19,6 +19,7 @@ ".": "./src/index.ts", "./core": "./src/index.ts", "./shared": "./src/shared.ts", + "./host-internal": "./src/host-internal.ts", "./http-source": "./src/http-source.ts", "./promise": "./src/promise.ts", "./client": "./src/client.ts", @@ -45,6 +46,12 @@ "default": "./dist/shared.js" } }, + "./host-internal": { + "import": { + "types": "./dist/host-internal.d.ts", + "default": "./dist/host-internal.js" + } + }, "./http-source": { "import": { "types": "./dist/http-source.d.ts", @@ -82,10 +89,9 @@ "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", - "@types/better-sqlite3": "^7.6.13", + "@libsql/client": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", - "better-sqlite3": "^12.9.0", "drizzle-orm": "catalog:", "react": "catalog:", "tsup": "catalog:", diff --git a/packages/core/sdk/src/executor-fuma-db.ts b/packages/core/sdk/src/executor-fuma-db.ts new file mode 100644 index 000000000..def0dd957 --- /dev/null +++ b/packages/core/sdk/src/executor-fuma-db.ts @@ -0,0 +1,109 @@ +// --------------------------------------------------------------------------- +// Shared FumaDB assembly (pure, driver-agnostic). +// +// Every host (self-host, local, sdk-test, cloud) historically hand-rolled the +// same driver-agnostic FumaDB wiring: build a fumadb factory from the latest +// schema, bind it to an already-opened drizzle handle through `drizzleAdapter`, +// and expose `{ db: fuma.orm(version), fuma }`. `createExecutorFumaDb` owns ONLY +// that assembly — the caller still opens its own driver (libSQL for SQLite, +// postgres-js for Postgres), applies its own PRAGMAs, and runs its own schema +// bring-up. The factory is dialect-generic via the `provider` param. +// +// This is a pure helper, not the `DbProvider` Effect seam. The seam +// (`DbProvider` / `dbProviderLayer`) is host-composition and lives in the host +// layer (`@executor-js/api/server`). This assembly stays in the SDK because the +// SDK's own sqlite test backend (`sqlite-test-db.ts`) builds its handle with it; +// hosts reach it (and the seam) through `@executor-js/api/server`, which +// re-exports `createExecutorFumaDb` from here. It is NOT on the plugin-author +// root barrel — host code imports it from `@executor-js/sdk/host-internal`. +// --------------------------------------------------------------------------- + +import { fumadb, type FumaDB } from "fumadb"; +import { type DrizzleRuntimeProvider } from "fumadb/adapters/drizzle"; +import { drizzleAdapter } from "fumadb/adapters/drizzle"; +import { schema as fumaSchema, type RelationsMap } from "fumadb/schema"; + +import type { FumaDb, FumaTables } from "./fuma-runtime"; + +// The FumaDB provider both the runtime-schema generator and the drizzle adapter +// understand. SQLite (libSQL) and PostgreSQL (postgres-js) are the only +// dialects in use today. +export type ExecutorDbProvider = DrizzleRuntimeProvider; + +export type ExecutorFumaSchema = ReturnType< + typeof fumaSchema> +>; + +export interface ExecutorFumaDb { + readonly db: FumaDb>; + readonly fuma: FumaDB[]>; +} + +export interface CreateExecutorFumaDbOptions { + readonly tables: TTables; + readonly namespace: string; + readonly version: string; + readonly provider: ExecutorDbProvider; + /** + * Whether the engine supports interactive transactions (BEGIN/COMMIT). + * Defaults to `true`. Cloudflare D1 must pass `false` — it rejects + * interactive transactions, so the adapter runs transaction callbacks + * directly (auto-commit per statement). libSQL/Postgres keep real + * transactions. + */ + readonly interactiveTransactions?: boolean; + /** + * Maximum bound parameters per query (Cloudflare D1: 100). When set, + * `createMany` batches so `rows * columns` stays within it. Unset for + * libSQL/Postgres (no tight cap). + */ + readonly maxBoundParameters?: number; +} + +/** + * Driver-agnostic FumaDB assembly. The caller passes an already-opened drizzle + * handle (it owns the driver, PRAGMAs, and schema bring-up); this wires the + * fumadb client over it and returns the `{ db, fuma }` query surface. + * + * NOTE: the drizzle `db` must already have its runtime schema attached (via + * `createDrizzleRuntimeSchemaFromTables`) for SQLite/Postgres relational + * queries to resolve — that schema generation stays caller-side because it is + * coupled to the caller's drizzle() construction. + */ +export const createExecutorFumaDb = ( + drizzleDb: unknown, + options: CreateExecutorFumaDbOptions, +): ExecutorFumaDb => { + const latestSchema = fumaSchema({ + version: options.version, + tables: options.tables, + }); + const factory = fumadb({ + namespace: options.namespace, + schemas: [latestSchema], + }); + const fuma = factory.client( + drizzleAdapter({ + db: drizzleDb, + provider: options.provider, + interactiveTransactions: options.interactiveTransactions, + maxBoundParameters: options.maxBoundParameters, + }), + ); + + return { + db: fuma.orm(options.version), + fuma, + }; +}; + +// The uniform handle each host exposes through the `DbProvider` Layer (defined +// in the host layer). The `db`/`fuma` come from `createExecutorFumaDb`; `close` +// releases the host's own driver. Hosts that keep extra connection objects (the +// raw sqlite handle, the postgres `sql`) layer those into their own concrete +// handle type and still satisfy this contract. +export interface ExecutorDbHandle< + TTables extends FumaTables = FumaTables, +> extends ExecutorFumaDb { + readonly close: () => Promise; +} diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 35d858347..05caea8e4 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -134,12 +134,12 @@ import type { Scope } from "./scope"; import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; import { Usage } from "./usages"; import { - ToolSchema, + ToolSchemaView, type RefreshSourceInput, type RemoveSourceInput, type Source, type SourceDetectionResult, - type Tool, + type ToolView, type ToolListFilter, } from "./types"; import { buildToolTypeScriptPreview, type ToolTypeScriptPreview } from "./schema-types"; @@ -199,11 +199,11 @@ export type Executor = { readonly scopes: readonly Scope[]; readonly tools: { - readonly list: (filter?: ToolListFilter) => Effect.Effect; + readonly list: (filter?: ToolListFilter) => Effect.Effect; /** Fetch a tool's schema view: JSON schemas with `$defs` * attached from the core `definition` table, plus TypeScript * preview strings. Returns `null` for unknown tool ids. */ - readonly schema: (toolId: string) => Effect.Effect; + readonly schema: (toolId: string) => Effect.Effect; /** Every `$defs` entry across every source, grouped by source id. * Used for bulk schema export and downstream TypeScript rendering. */ readonly definitions: () => Effect.Effect< @@ -433,19 +433,24 @@ export interface ExecutorConfig { +export const collectTables = (): FumaTables => { validateExecutorScopePolicyTables(coreSchema); return { ...coreSchema }; }; @@ -489,7 +494,7 @@ const createDefaultMemoryDb = (tables: FumaTables): ExecutorDb => { schemas: [latestSchema], }); - // oxlint-disable-next-line executor/no-double-cast -- boundary: dynamic plugin table map is known only after collectTables() + // oxlint-disable-next-line executor/no-double-cast -- boundary: fumadb's generic ORM client type doesn't structurally match the FumaDb facade const db = factory.client(memoryAdapter()).orm(version) as unknown as FumaDb; return { db, @@ -539,7 +544,7 @@ const decodeJsonColumn = (value: unknown): unknown => { const decodeProviderState = Schema.decodeUnknownOption(ConnectionProviderState); const decodeConnectionIdentityOverride = Schema.decodeUnknownOption(ConnectionIdentityOverride); -const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => ({ +const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): ToolView => ({ id: row.id, sourceId: row.source_id, pluginId: row.plugin_id, @@ -554,7 +559,7 @@ const staticDeclToTool = ( source: StaticSourceDecl, tool: StaticToolDecl, pluginId: string, -): Tool => ({ +): ToolView => ({ id: `${source.id}.${tool.name}`, sourceId: source.id, pluginId, @@ -1288,7 +1293,7 @@ const writeDefinitions = ( // so `tools.list({ query, sourceId })` matches across both. // --------------------------------------------------------------------------- -const toolMatchesFilter = (tool: Tool, filter: ToolListFilter): boolean => { +const toolMatchesFilter = (tool: ToolView, filter: ToolListFilter): boolean => { if (filter.sourceId && tool.sourceId !== filter.sourceId) return false; if (filter.query) { const q = filter.query.toLowerCase(); @@ -1355,7 +1360,7 @@ export const createExecutor = collectTables(plugins), + try: () => collectTables(), catch: (cause) => storageFailureFromUnknown("Failed to collect executor tables", cause), }); const dbInput = yield* Effect.suspend(() => { @@ -3712,7 +3717,7 @@ export const createExecutor = 0) { - const kept: Tool[] = []; + const kept: ToolView[] = []; for (const tool of filtered) { const match = resolveToolPolicy(tool.id, policies, scopeRank); if (match?.action === "block") { @@ -3775,7 +3780,7 @@ export const createExecutor = => Effect.gen(function* () { const rows = yield* secretRowsForId(id); - if (rows.some((row) => row.owned_by_connection_id)) return "missing"; + // Connection-owned rows are managed through their connection, not the + // picker — skip them (as `secretsList` does) rather than letting one + // poison the whole status. A co-existing org-default value still + // resolves the secret. for (const row of rows) { + if (row.owned_by_connection_id) continue; if (yield* secretRouteHasBackingValue(row)) return "resolved"; } diff --git a/packages/core/sdk/src/host-internal.ts b/packages/core/sdk/src/host-internal.ts new file mode 100644 index 000000000..a5e26f92a --- /dev/null +++ b/packages/core/sdk/src/host-internal.ts @@ -0,0 +1,38 @@ +// --------------------------------------------------------------------------- +// @executor-js/sdk/host-internal — host-composition seams that live in the SDK +// only because the SDK's own internals share their implementation. +// +// This entry is NOT part of the plugin-author contract (the root barrel). It +// exists so the host layer (`@executor-js/api/server`) can reach SDK-resident +// host machinery without that machinery polluting the plugin-author surface: +// +// - `makeHostedHttpClientLayer` / `HostedOutboundRequestBlocked`: the hosted +// HTTP client builder. `validateHostedOutboundUrl` is consumed by +// `createExecutor`'s built-in `fetch` tool (the SSRF guard), which is why +// the module stays in the SDK rather than moving wholesale to the host. +// - `createExecutorFumaDb` + its types: the pure, driver-agnostic FumaDB +// assembly. It stays in the SDK because the SDK's own sqlite test backend +// builds its handle with it; the host layer re-exports it (and pairs it +// with the `DbProvider` Effect seam) from `@executor-js/api/server`. +// - `assertSupportedOAuthEndpointUrl` / `OAUTH2_DEFAULT_TIMEOUT_MS`: the OAuth +// endpoint SSRF guard + default fetch timeout. The SDK's own OAuth flow uses +// them; the host's connection-identity handler reuses the same guard/timeout +// when fetching OIDC userinfo, so they surface here (not the plugin barrel). +// --------------------------------------------------------------------------- + +export { + HostedOutboundRequestBlocked, + makeHostedHttpClientLayer, + type HostedHttpClientOptions, +} from "./hosted-http-client"; + +export { OAUTH2_DEFAULT_TIMEOUT_MS, assertSupportedOAuthEndpointUrl } from "./oauth-helpers"; + +export { + createExecutorFumaDb, + type CreateExecutorFumaDbOptions, + type ExecutorDbHandle, + type ExecutorDbProvider, + type ExecutorFumaDb, + type ExecutorFumaSchema, +} from "./executor-fuma-db"; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 2a2c60a2a..2787f1b7c 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -41,7 +41,13 @@ export { StorageError, UniqueViolationError, isStorageFailure } from "./fuma-run export { ScopeId, ToolId, SecretId, PolicyId, ConnectionId, CredentialBindingId } from "./ids"; // Scope -export { Scope, defaultSourceInstallScopeId } from "./scope"; +export { + Scope, + defaultSourceInstallScopeId, + userOrgScopeId, + parseUserOrgScopeId, + makeUserOrgScopeStack, +} from "./scope"; // Errors (tagged) export { @@ -66,12 +72,12 @@ export { // Public projections export { - ToolSchema, + ToolSchemaView, SourceDetectionResult, type RefreshSourceInput, type RemoveSourceInput, type Source, - type Tool, + type ToolView, type ToolListFilter, } from "./types"; @@ -105,14 +111,15 @@ export { type ToolAnnotations, } from "./core-schema"; -// Tool policies +// Tool policies. `matchPattern`/`isValidPattern` are consumed by the React UI; +// `effectivePolicyFromSorted` + `ToolPolicyActionSchema` are shared contracts. +// `resolveToolPolicy`/`resolveEffectivePolicy`/`rowToToolPolicy` are off the +// barrel: they are SDK-internal (used inside `createExecutor`), not a plugin or +// consumer contract. export { matchPattern, isValidPattern, - resolveToolPolicy, - resolveEffectivePolicy, effectivePolicyFromSorted, - rowToToolPolicy, ToolPolicyActionSchema, type ToolPolicy, type CreateToolPolicyInput, @@ -192,14 +199,10 @@ export { type ElicitationContext, } from "./elicitation"; -// Blob store -export { - type BlobStore, - type PluginBlobStore, - pluginBlobStore, - makeFumaBlobStore, - makeInMemoryBlobStore, -} from "./blob"; +// Blob store — the plugin-facing CONTRACT only. The concrete makers +// (`makeFumaBlobStore`/`makeInMemoryBlobStore`) are SDK-internal: `createExecutor` +// wires the blob store, plugins only ever receive a `PluginBlobStore`. +export { type BlobStore, type PluginBlobStore, pluginBlobStore } from "./blob"; // Plugin storage export { @@ -259,35 +262,13 @@ export { OAuthClientCredentialsStrategy as OAuthClientCredentialsStrategySchema, } from "./oauth"; -export { - OAuth2Error, - OAUTH2_DEFAULT_TIMEOUT_MS, - OAUTH2_REFRESH_SKEW_MS, - assertSupportedOAuthEndpointUrl, - buildAuthorizationUrl, - createPkceCodeChallenge, - createPkceCodeVerifier, - exchangeAuthorizationCode, - exchangeClientCredentials, - isSupportedOAuthEndpointUrl, - refreshAccessToken, - shouldRefreshToken, - type OAuth2TokenResponse, - type BuildAuthorizationUrlInput, - type ClientAuthMethod, - type ExchangeAuthorizationCodeInput, - type ExchangeClientCredentialsInput, - type RefreshAccessTokenInput, -} from "./oauth-helpers"; - -export { makeOAuth2Service, type OAuthServiceDeps } from "./oauth-service"; - -export { - HostedOutboundRequestBlocked, - makeHostedHttpClientLayer, - validateHostedOutboundUrl, - type HostedHttpClientOptions, -} from "./hosted-http-client"; +// NOTE: the OAuth 2.1 implementation helpers (PKCE/exchange/refresh in +// `./oauth-helpers`, `makeOAuth2Service` in `./oauth-service`, and the dynamic +// discovery/registration in `./oauth-discovery`) are SDK-internal: they are +// consumed only by `createExecutor`'s built-in OAuth flow, never by plugins. +// The plugin-facing OAuth CONTRACTS (the schemas/types + `OAUTH2_PROVIDER_KEY`) +// stay exported above. The hosted HTTP client builder is host-internal too and +// reachable via `@executor-js/sdk/host-internal`. export { DEFAULT_EXECUTOR_SERVER_ORIGIN, @@ -303,26 +284,6 @@ export { type ExecutorServerConnectionKind, } from "./server-connection"; -export { - OAuthDiscoveryError, - OAuthAuthorizationServerMetadataSchema, - OAuthClientInformationSchema, - OAuthProtectedResourceMetadataSchema, - beginDynamicAuthorization, - discoverAuthorizationServerMetadata, - discoverProtectedResourceMetadata, - registerDynamicClient, - type BeginDynamicAuthorizationInput, - type DiscoveryRequestOptions, - type DynamicAuthorizationState, - type DynamicAuthorizationStartResult, - type DynamicClientMetadata, - type OAuthAuthorizationServerMetadata, - type OAuthClientInformation, - type OAuthProtectedResourceMetadata, - type RegisterDynamicClientInput, -} from "./oauth-discovery"; - export { OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupResult, @@ -355,6 +316,13 @@ export { } from "./plugin"; // Executor +// +// `collectTables` is host/tooling-only (cli schema cmd, kernel worker, +// local/cloud DB bring-up). Its definition stays here because `createExecutor` +// uses it; the host surface (`@executor-js/api/server`) re-exports it so hosts +// import it alongside the other host-composition seams. The CLI + kernel +// tooling, which only depend on `@executor-js/sdk` (not `@executor-js/api`), +// keep importing it from here. export { type Executor, type ExecutorConfig, @@ -367,11 +335,12 @@ export { collectTables, } from "./executor"; -// Built-in core-tools plugin (scopes.list, secrets.list, secrets.create -// with URL elicitation). Auto-registered by createExecutor when -// `coreTools` is set on the config; also exportable for callers who -// want to register it manually. -export { coreToolsPlugin, type CoreToolsPluginOptions } from "./core-tools"; +// NOTE: the host-composition seams (`DbProvider`/`dbProviderLayer`, +// `makeScopedExecutor`/`HostConfig`/`PluginsProvider`, `createExecutorFumaDb`) +// are NOT on this plugin-author barrel — they live in the host surface +// (`@executor-js/api/server`). The pure FumaDB assembly stays in the SDK for the +// sqlite test backend and is exposed to the host layer via +// `@executor-js/sdk/host-internal`. // CLI / runtime config export { @@ -380,26 +349,19 @@ export { type ExecutorPluginsFactory, } from "./config"; -// JSON schema $ref helpers (used by openapi for $defs handling) -export { hoistDefinitions, collectRefs, reattachDefs, normalizeRefs } from "./schema-refs"; - -// TypeScript preview generation from JSON schemas -export { - schemaToTypeScriptPreview, - schemaToTypeScriptPreviewWithDefs, - buildToolTypeScriptPreview, - type TypeScriptRenderOptions, - type TypeScriptSchemaPreview, -} from "./schema-types"; +// NOTE: the JSON-schema `$ref` helpers (`./schema-refs`) and most TypeScript +// preview generators (`./schema-types`) are SDK-internal — `./schema-types` +// consumes `./schema-refs` and is used inside `createExecutor`. The one +// exception is `buildToolTypeScriptPreview`: plugins assert the TS preview of +// their derived tools (the openapi Google-discovery suite), so it is exported. +export { buildToolTypeScriptPreview } from "./schema-types"; // Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions. export { InternalError } from "./api-errors"; // ToolResult — typed value-based discriminated union for tool outcomes. -// The `Tool` value namespace exposes `Tool.ok` / `Tool.fail` constructors; -// the `Tool` type alias from `./types` is a separate row projection. -// TypeScript permits the two to share a name because one is purely a -// value and the other purely a type. +// Distinct from the `ToolView` row projection (`./types`) and the `tool()` +// builder (`./plugin`): one word per concept, three names. export { ToolResult, isToolResult, type ToolError } from "./tool-result"; export { authToolFailure, diff --git a/packages/core/sdk/src/promise-executor.ts b/packages/core/sdk/src/promise-executor.ts index e420b7e5d..debaf6f2c 100644 --- a/packages/core/sdk/src/promise-executor.ts +++ b/packages/core/sdk/src/promise-executor.ts @@ -94,9 +94,9 @@ export interface ExecutorConfig> => { const plugins = (config?.plugins ?? []) as TPlugins; const db = - typeof config.db === "function" - ? await config.db({ tables: collectTables(plugins) }) - : config.db; + typeof config.db === "function" ? await config.db({ tables: collectTables() }) : config.db; const scopes = config.scopes && config.scopes.length > 0 diff --git a/packages/core/sdk/src/promise.ts b/packages/core/sdk/src/promise.ts index 67cbb9d87..e9e88ee30 100644 --- a/packages/core/sdk/src/promise.ts +++ b/packages/core/sdk/src/promise.ts @@ -24,12 +24,12 @@ export type { UpdateToolPolicyInput, } from "./policies"; export { - ToolSchema, + ToolSchemaView, SourceDetectionResult, type RefreshSourceInput, type RemoveSourceInput, type Source, - type Tool, + type ToolView, type ToolListFilter, } from "./types"; export type { ToolAnnotations } from "./core-schema"; diff --git a/packages/core/sdk/src/scope-policy.test.ts b/packages/core/sdk/src/scope-policy.test.ts index 49b56e6ad..e45615443 100644 --- a/packages/core/sdk/src/scope-policy.test.ts +++ b/packages/core/sdk/src/scope-policy.test.ts @@ -74,7 +74,7 @@ describe("executor FumaDB scope policy", () => { Effect.promise(() => createSqliteTestFumaDb({ tables: { - ...collectTables([]), + ...collectTables(), ...unscopedSchema, }, namespace: "executor_unscoped_test", @@ -102,7 +102,7 @@ describe("executor FumaDB scope policy", () => { Effect.promise(() => createSqliteTestFumaDb({ tables: { - ...collectTables([]), + ...collectTables(), ...incompletePolicySchema, }, namespace: "executor_incomplete_policy_test", diff --git a/packages/core/sdk/src/scope.test.ts b/packages/core/sdk/src/scope.test.ts new file mode 100644 index 000000000..f375bca61 --- /dev/null +++ b/packages/core/sdk/src/scope.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { makeUserOrgScopeStack, parseUserOrgScopeId, userOrgScopeId } from "./scope"; + +// The exact regex the workos-vault plugin used to inline. The parser must stay +// byte-for-byte equivalent to it, so we keep a private copy here purely to +// prove equivalence (the production parser references the SDK helper instead). +const LEGACY_REGEX = /^user-org:([^:]+):([^:]+)$/; + +const legacyParse = ( + id: string, +): { readonly userId: string; readonly organizationId: string } | null => { + const m = id.match(LEGACY_REGEX); + return m ? { userId: m[1]!, organizationId: m[2]! } : null; +}; + +describe("userOrgScopeId / parseUserOrgScopeId", () => { + it("produces the exact contract string", () => { + expect(userOrgScopeId("u1", "org42")).toBe("user-org:u1:org42"); + }); + + it.each([ + ["u1", "org42"], + // Tricky-but-colon-free ids: uuids, dashes, dots, unicode, encoded chars. + ["1a2b-c3d4", "00000000-0000-0000-0000-000000000000"], + ["user.with.dots", "org_underscore"], + ["usér", "örg"], + ["a b c", "o r g"], + ["user%3Aslash", "org+plus"], + ])("round-trips parse(build(%j, %j))", (userId, organizationId) => { + const parsed = parseUserOrgScopeId(userOrgScopeId(userId, organizationId)); + expect(parsed).toEqual({ userId, organizationId }); + }); + + // The legacy regex requires non-empty segments, so a built id with an empty + // segment does NOT round-trip. The empty-segment cases live in the + // equivalence block below (`user-org::b`, `user-org:a:`). + + // Equivalence proof: for representative + adversarial inputs the new parser + // must return exactly what the inlined workos-vault regex returned. + it.each([ + "user-org:u1:org42", + "user-org:a:b", + "user-org::b", // empty user segment -> no match (greedy [^:]+ needs >=1) + "user-org:a:", // empty org segment -> no match + "user-org:a:b:c", // extra colon -> no match (anchored, exactly two segments) + "user-org:a", // missing org segment -> no match + "user-org:", // nothing -> no match + "user-org:a:b ", // trailing space is part of the org segment -> matches + " user-org:a:b", // leading space breaks the anchor -> no match + "USER-ORG:a:b", // case-sensitive prefix -> no match + "org42", // bare org id -> no match + "user-org:a:b\nuser-org:c:d", // newline: $ would normally allow, but no `m` flag + "prefix-user-org:a:b", // prefix not anchored -> no match + "", + ])("matches the legacy regex for %j", (id) => { + expect(parseUserOrgScopeId(id)).toEqual(legacyParse(id)); + }); +}); + +describe("makeUserOrgScopeStack", () => { + it("builds [userOrgScope, orgScope] with byte-identical ids + naming", () => { + const [userOrgScope, orgScope] = makeUserOrgScopeStack("u1", "org42", "Acme"); + + expect(String(userOrgScope.id)).toBe("user-org:u1:org42"); + expect(userOrgScope.name).toBe("Personal · Acme"); + + expect(String(orgScope.id)).toBe("org42"); + expect(orgScope.name).toBe("Acme"); + }); + + it("orders innermost (user-org) first so per-user secrets isolate", () => { + const stack = makeUserOrgScopeStack("u1", "org42", "Acme"); + expect(stack.map((s) => String(s.id))).toEqual(["user-org:u1:org42", "org42"]); + }); +}); diff --git a/packages/core/sdk/src/scope.ts b/packages/core/sdk/src/scope.ts index 9fd34cacd..c9771b3d8 100644 --- a/packages/core/sdk/src/scope.ts +++ b/packages/core/sdk/src/scope.ts @@ -9,6 +9,79 @@ export const Scope = Schema.Struct({ }); export type Scope = typeof Scope.Type; +// --------------------------------------------------------------------------- +// User-org scope id — the per-user secret-isolation contract. +// +// A cloud/self-host executor's scope stack is `[userOrgScope, orgScope]` +// (innermost first). The inner scope id bakes the org into the user id so the +// same WorkOS user in a different org gets a distinct scope row, and per-user +// secrets/tokens written at this scope cannot leak to other members of the org. +// +// This id is produced by the host apps and *parsed* by the workos-vault plugin +// (to split it into per-field KEK context). Producer and parser MUST agree, so +// both reference the helpers below as the single source of truth. Do NOT change +// the string shape without updating every consumer in lockstep. +// --------------------------------------------------------------------------- + +const USER_ORG_SCOPE_PREFIX = "user-org:"; + +// Mirrors the historical workos-vault regex `^user-org:([^:]+):([^:]+)$`: +// the `user-org:` prefix followed by exactly two colon-free, non-empty +// segments. Kept anchored to a const so the producer and parser cannot drift. +const USER_ORG_SCOPE_ID_REGEX = /^user-org:([^:]+):([^:]+)$/; + +/** + * Build the per-user-within-org scope id. The single source of truth for the + * `user-org:${userId}:${organizationId}` string shape. + */ +export const userOrgScopeId = (userId: string, organizationId: string): string => + `${USER_ORG_SCOPE_PREFIX}${userId}:${organizationId}`; + +/** + * Inverse of {@link userOrgScopeId}. Returns the `{ userId, organizationId }` + * pair for a user-org scope id, or `null` for any other scope shape. + * + * Behaviour is identical to the legacy workos-vault regex + * `^user-org:([^:]+):([^:]+)$`: both segments are matched greedily as + * colon-free, non-empty runs, so an id with extra colons (e.g. + * `user-org:a:b:c`) or an empty segment does not match. userId/organizationId + * may be otherwise opaque. + */ +export const parseUserOrgScopeId = ( + id: string, +): { readonly userId: string; readonly organizationId: string } | null => { + const m = id.match(USER_ORG_SCOPE_ID_REGEX); + if (!m) return null; + return { userId: m[1]!, organizationId: m[2]! }; +}; + +/** + * Build the canonical `[userOrgScope, orgScope]` scope stack (innermost first) + * shared by the cloud and self-host per-request executors. The inner scope is + * named `Personal · ${organizationName}`; the outer scope is the bare org. + * + * Centralising this keeps the id shape and naming byte-identical across hosts + * and in lockstep with {@link parseUserOrgScopeId}. + */ +export const makeUserOrgScopeStack = ( + userId: string, + organizationId: string, + organizationName: string, +): readonly [Scope, Scope] => { + const createdAt = new Date(); + const userOrgScope = Scope.make({ + id: ScopeId.make(userOrgScopeId(userId, organizationId)), + name: `Personal · ${organizationName}`, + createdAt, + }); + const orgScope = Scope.make({ + id: ScopeId.make(organizationId), + name: organizationName, + createdAt, + }); + return [userOrgScope, orgScope]; +}; + /** * Source-add flows that do not expose a user-facing placement choice install * sources at the outermost visible scope. Local executors have one scope, while diff --git a/packages/core/sdk/src/sqlite-test-db.ts b/packages/core/sdk/src/sqlite-test-db.ts index 5b52ce607..ea95be445 100644 --- a/packages/core/sdk/src/sqlite-test-db.ts +++ b/packages/core/sdk/src/sqlite-test-db.ts @@ -1,15 +1,15 @@ -import Database from "better-sqlite3"; -import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import { createClient, type Client } from "@libsql/client"; +import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql"; import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import { fumadb, type FumaDB } from "fumadb"; +import { dirname, resolve } from "node:path"; +import { type FumaDB } from "fumadb"; import { createDrizzleRuntimeSchemaFromTables, createDrizzleRuntimeSchemaSqlFromTables, - drizzleAdapter, } from "fumadb/adapters/drizzle"; -import { schema as fumaSchema, type RelationsMap } from "fumadb/schema"; +import { type schema as fumaSchema, type RelationsMap } from "fumadb/schema"; +import { createExecutorFumaDb } from "./executor-fuma-db"; import type { FumaDb, FumaTables } from "./fuma-runtime"; type SqliteTestFumaSchema = ReturnType< @@ -19,8 +19,8 @@ type SqliteTestFumaSchema = ReturnType< export interface SqliteTestFumaDb { readonly db: FumaDb>; readonly fuma: FumaDB[]>; - readonly drizzle: BetterSQLite3Database>; - readonly sqlite: Database.Database; + readonly drizzle: LibSQLDatabase>; + readonly client: Client; readonly close: () => Promise; } @@ -39,8 +39,13 @@ export const createSqliteTestFumaDb = async ( if (options.path && options.path !== ":memory:") { mkdirSync(dirname(options.path), { recursive: true }); } - const sqlite = new Database(options.path ?? ":memory:"); - sqlite.pragma("foreign_keys = ON"); + // libSQL `:memory:` is a single connection per client, matching the test's + // single-handle expectation. foreign_keys is per-connection (no shared + // handle to inherit it), so set it on this one. + const url = + !options.path || options.path === ":memory:" ? ":memory:" : `file:${resolve(options.path)}`; + const client = createClient({ url }); + await client.execute("PRAGMA foreign_keys = ON"); const schema = createDrizzleRuntimeSchemaFromTables({ tables: options.tables, @@ -48,7 +53,7 @@ export const createSqliteTestFumaDb = async ( version, provider: "sqlite", }); - const drizzleDb = drizzle(sqlite, { schema }); + const drizzleDb = drizzle({ client, schema }); for (const statement of createDrizzleRuntimeSchemaSqlFromTables({ tables: options.tables, @@ -56,31 +61,23 @@ export const createSqliteTestFumaDb = async ( version, provider: "sqlite", })) { - sqlite.exec(statement); + await client.execute(statement); } - const latestSchema = fumaSchema({ - version, + const { db, fuma } = createExecutorFumaDb(drizzleDb, { tables: options.tables, - }); - const factory = fumadb({ namespace, - schemas: [latestSchema], + version, + provider: "sqlite", }); - const fuma = factory.client( - drizzleAdapter({ - db: drizzleDb, - provider: "sqlite", - }), - ); return { - db: fuma.orm(version), + db, fuma, drizzle: drizzleDb, - sqlite, + client, close: async () => { - sqlite.close(); + client.close(); }, }; }; diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index 1b5e479de..7cf21ebc6 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -126,7 +126,7 @@ export const makeTestConfig = Durable-Object internal wire protocol headers + the trace/header +// plumbing the worker stamps before forwarding to the MCP session DO. +// +// The worker stamps the verified caller identity onto these headers before +// forwarding a request to the MCP session Durable Object; the DO reads them +// back to validate ownership against its stored session meta. Single-sourced +// here so the producer (worker, see withVerifiedIdentityHeaders) and the +// consumer (the DO, in session-durable-object.ts) cannot drift. +// +// This module stays react-start-free (it only uses `effect` + Web APIs) so the +// DO worker bundle that reaches it can be bundled by wrangler/esbuild. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; +export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; + +const TRUE_QUERY_VALUES = new Set(["1", "true", "yes", "on"]); + +/** The verified identity used to stamp the DO's internal owner headers. */ +export type VerifiedTokenHeaders = { + readonly accountId: string; + readonly organizationId: string; +}; + +// Worker and DO run in separate isolates with independent WebSdk tracer +// providers. Neither one can see the other's OTEL context, so the DO used +// to emit a brand-new root trace on every stub call. Ferry the worker span +// context across with W3C headers: `traceparent` generated from the active +// Effect span plus passthrough `tracestate` / `baggage` from the inbound +// request. +export type IncomingPropagationHeaders = { + readonly traceparent?: string; + readonly tracestate?: string; + readonly baggage?: string; +}; + +const currentTraceparent = Effect.map(Effect.currentSpan, (span) => { + if (!span || !span.traceId || !span.spanId) return undefined; + const flags = span.sampled ? "01" : "00"; + return `00-${span.traceId}-${span.spanId}-${flags}`; +}).pipe(Effect.orElseSucceed(() => undefined)); + +export const currentPropagationHeaders = ( + request: Request, +): Effect.Effect => + Effect.map(currentTraceparent, (traceparent) => ({ + traceparent, + tracestate: request.headers.get("tracestate") ?? undefined, + baggage: request.headers.get("baggage") ?? undefined, + })); + +export const withPropagationHeaders = ( + request: Request, + propagation: IncomingPropagationHeaders, +): Request => { + const headers = new Headers(request.headers); + if (propagation.traceparent) { + headers.set("traceparent", propagation.traceparent); + } + if (propagation.tracestate) { + headers.set("tracestate", propagation.tracestate); + } + if (propagation.baggage) { + headers.set("baggage", propagation.baggage); + } + return new Request(request, { headers }); +}; + +export const withVerifiedIdentityHeaders = ( + request: Request, + token: VerifiedTokenHeaders, +): Request => { + const headers = new Headers(request.headers); + headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId); + headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? ""); + return new Request(request, { headers }); +}; + +export const withMcpResponseHeaders = (response: Response): Response => { + const headers = new Headers(response.headers); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-expose-headers", "mcp-session-id"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +export type McpElicitationMode = "browser" | "model" | "native"; + +const MCP_ELICITATION_MODES = new Set(["browser", "model", "native"]); + +export const readElicitationMode = (request: Request): McpElicitationMode => { + const url = new URL(request.url); + const mode = url.searchParams.get("elicitation_mode"); + if (mode && MCP_ELICITATION_MODES.has(mode as McpElicitationMode)) { + return mode as McpElicitationMode; + } + + const legacyModelResume = url.searchParams.get("allow_model_resume"); + if (legacyModelResume !== null && TRUE_QUERY_VALUES.has(legacyModelResume.toLowerCase())) { + return "model"; + } + + return "model"; +}; diff --git a/apps/cloud/src/mcp/response-peek.ts b/packages/hosts/cloudflare/src/mcp/response-peek.ts similarity index 88% rename from apps/cloud/src/mcp/response-peek.ts rename to packages/hosts/cloudflare/src/mcp/response-peek.ts index ad7a78ca2..cb0403beb 100644 --- a/apps/cloud/src/mcp/response-peek.ts +++ b/packages/hosts/cloudflare/src/mcp/response-peek.ts @@ -1,9 +1,12 @@ -import * as Sentry from "@sentry/cloudflare"; import { Cause, Data, Effect, Exit, Option, Schema } from "effect"; -import { jsonRpcWebResponse } from "./responses"; +import { jsonRpcErrorBody } from "@executor-js/host-mcp"; -const SSE_PEEK_TIMEOUT_MS = 10_000; +const DEFAULT_SSE_PEEK_TIMEOUT_MS = 10_000; + +/** Observe a JSON-RPC internal error (-32603) seen on a peeked response. The + * host injects this (cloud: Sentry capture; host-cloudflare: console / omit). */ +export type OnInternalJsonRpcError = (message: string) => void; class ResponseBodyTimeoutError extends Data.TaggedError("ResponseBodyTimeoutError")<{ readonly timeoutMs: number; @@ -11,10 +14,6 @@ class ResponseBodyTimeoutError extends Data.TaggedError("ResponseBodyTimeoutErro class ResponseBodyReadError extends Data.TaggedError("ResponseBodyReadError") {} -class McpInternalJsonRpcError extends Data.TaggedError("McpInternalJsonRpcError")<{ - readonly message: string; -}> {} - const ResponseBodyTimeoutErrorData = Schema.Struct({ _tag: Schema.Literal("ResponseBodyTimeoutError"), timeoutMs: Schema.Number, @@ -179,7 +178,7 @@ const responseReadFailure = (error: unknown) => "mcp.peek_response.timed_out": timedOut, "mcp.peek_response.error": timedOut ? "ResponseBodyTimeoutError" : "ResponseBodyReadError", }); - return jsonRpcWebResponse( + return jsonRpcErrorBody( timedOut ? 504 : 500, -32001, timedOut @@ -188,14 +187,26 @@ const responseReadFailure = (error: unknown) => ); }); -const reportInternalJsonRpcError = (payload: JsonRpcResponseBody | null) => +const reportInternalJsonRpcError = ( + payload: JsonRpcResponseBody | null, + onInternalError: OnInternalJsonRpcError | undefined, +) => Effect.sync(() => { if (payload?.error?.code !== -32603) return; - const message = payload.error["message"] ?? "unknown"; - Sentry.captureException(new McpInternalJsonRpcError({ message })); + onInternalError?.(payload.error["message"] ?? "unknown"); }); -export const peekAndAnnotate = (response: Response): Effect.Effect => +export interface PeekAndAnnotateOptions { + /** Observe a JSON-RPC -32603 internal error (cloud injects Sentry capture). */ + readonly onInternalError?: OnInternalJsonRpcError; + /** SSE body read timeout (defaults to 10s). */ + readonly sseTimeoutMs?: number; +} + +export const peekAndAnnotate = ( + response: Response, + options: PeekAndAnnotateOptions = {}, +): Effect.Effect => Effect.gen(function* () { const contentType = response.headers.get("content-type") ?? ""; if (response.status === 202) { @@ -208,7 +219,7 @@ export const peekAndAnnotate = (response: Response): Effect.Effect => } const isSseResponse = contentType.includes("text/event-stream"); - const timeoutMs = isSseResponse ? SSE_PEEK_TIMEOUT_MS : null; + const timeoutMs = isSseResponse ? (options.sseTimeoutMs ?? DEFAULT_SSE_PEEK_TIMEOUT_MS) : null; const textExit = yield* Effect.exit( Effect.tryPromise({ try: () => readResponseText(response, timeoutMs), @@ -242,7 +253,7 @@ export const peekAndAnnotate = (response: Response): Effect.Effect => }); const attrs = jsonRpcResponseAttrs(payload); if (Object.keys(attrs).length > 0) yield* Effect.annotateCurrentSpan(attrs); - yield* reportInternalJsonRpcError(payload); + yield* reportInternalJsonRpcError(payload, options.onInternalError); return new Response(text, { status: response.status, diff --git a/packages/hosts/cloudflare/src/mcp/seams.ts b/packages/hosts/cloudflare/src/mcp/seams.ts new file mode 100644 index 000000000..330f78200 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/seams.ts @@ -0,0 +1,28 @@ +import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; + +// --------------------------------------------------------------------------- +// The injection seams shared between the worker-side DO dispatcher and the +// DO-side base class. A host (cloud / host-cloudflare) supplies its own DO +// namespace + runtime builder; everything else is platform-generic. +// --------------------------------------------------------------------------- + +/** What the worker tells the session DO at creation (owner + elicitation mode). */ +export interface McpSessionInit { + readonly organizationId: string; + readonly userId: string; + readonly elicitationMode: McpElicitationMode; + /** Public origin of the create request (`https://host`), so the DO derives a + * web base URL zero-config when the host configures no static one. */ + readonly webOrigin?: string; +} + +/** + * The RPC surface the worker calls on a session-DO stub. The DO base class + * (McpSessionDOBase) implements these; a host's concrete DO subclass inherits + * them. The worker dispatcher only depends on this interface, not the class. + */ +export interface McpSessionDOStub { + init(meta: McpSessionInit, propagation: IncomingPropagationHeaders): Promise; + handleRequest(request: Request): Promise; + clearSession(propagation: IncomingPropagationHeaders): Promise; +} diff --git a/apps/cloud/src/mcp-session.ts b/packages/hosts/cloudflare/src/mcp/session-durable-object.ts similarity index 73% rename from apps/cloud/src/mcp-session.ts rename to packages/hosts/cloudflare/src/mcp/session-durable-object.ts index 25a55c635..8f12c9ffb 100644 --- a/apps/cloud/src/mcp-session.ts +++ b/packages/hosts/cloudflare/src/mcp/session-durable-object.ts @@ -1,58 +1,48 @@ // --------------------------------------------------------------------------- -// MCP Session Durable Object — holds MCP server + engine per session +// Shared MCP Session Durable Object base — holds the MCP server + engine for ONE +// session in a single addressable isolate (the DO id IS the mcp-session-id), so +// every follow-up request routes back to the same isolate. Owns ALL the +// platform-generic lifecycle (cold-restore from ctx.storage, the inactivity +// alarm, owner validation, the JSON-response-mode transport upgrade, the +// per-request→per-session span bridge, the browser-approval store). A host +// supplies only the seams: openSessionDb / resolveSessionMeta / buildMcpServer, +// and optionally withTelemetry / captureCause. cloud and host-cloudflare each +// become a ~100-line subclass binding their injected dependencies. // --------------------------------------------------------------------------- -import { DurableObject, env } from "cloudflare:workers"; -import { createTraceState } from "@opentelemetry/api"; -import { Cause, Data, Deferred, Effect, Layer } from "effect"; -import * as OtelTracer from "@effect/opentelemetry/Tracer"; +import { DurableObject } from "cloudflare:workers"; +import { Cause, Deferred, Effect } from "effect"; import type * as Tracer from "effect/Tracer"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { TransportState } from "agents/mcp"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres, { type Sql } from "postgres"; -import { createExecutorMcpServer } from "@executor-js/host-mcp"; +import { jsonRpcErrorBody } from "@executor-js/host-mcp"; +import { RequestWebOrigin } from "@executor-js/api/server"; import { - buildExecuteDescription, formatPausedExecution, type ExecutionEngine, type ResumeResponse, } from "@executor-js/execution"; -import type { DrizzleDb, DbServiceShape } from "./services/db"; - -// Import directly from core-shared-services, NOT from ./api/layers.ts. -// The full layers module pulls in `auth/handlers.ts` → `@tanstack/react-start/server`, -// which uses a `#tanstack-start-entry` subpath specifier that breaks module -// load under vitest-pool-workers. The DO only needs the core two services -// (WorkOSAuth + AutumnService), so we import them from the tight module. -import { CoreSharedServices } from "./api/core-shared-services"; -import { UserStoreService } from "./auth/context"; -import { resolveOrganization } from "./auth/resolve-organization"; -import { DbService, combinedSchema, resolveConnectionString } from "./services/db"; -import { makeExecutionStack } from "./services/execution-stack"; -import { makeMcpWorkerTransport, type McpWorkerTransport } from "./services/mcp-worker-transport"; -import { DoTelemetryLive } from "./services/telemetry"; -import { captureCause } from "./observability"; + +import { makeMcpWorkerTransport, type McpWorkerTransport } from "./worker-transport"; +import { + INTERNAL_ACCOUNT_ID_HEADER, + INTERNAL_ORGANIZATION_ID_HEADER, + type IncomingPropagationHeaders, +} from "./do-headers"; +import type { McpSessionInit } from "./seams"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export type McpSessionInit = { - organizationId: string; - userId: string; - elicitationMode?: "browser" | "model" | "native"; - allowModelResume?: boolean; -}; +export type { McpSessionInit } from "./seams"; -export type IncomingTraceHeaders = { - readonly traceparent?: string; - readonly tracestate?: string; - readonly baggage?: string; -}; +/** The W3C trace headers the worker forwards to the DO (same shape as the + * dispatcher's propagation headers). */ +export type IncomingTraceHeaders = IncomingPropagationHeaders; -export type McpSessionApprovalIdentity = { +export type McpApprovalOwner = { readonly accountId: string; readonly organizationId: string; }; @@ -105,162 +95,81 @@ const resumeApprovalResult = ( const HEARTBEAT_MS = 30 * 1000; const SESSION_TIMEOUT_MS = 5 * 60 * 1000; -const LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS = 5; -const LONG_LIVED_DB_MAX_LIFETIME_SECONDS = 120; const TRANSPORT_STATE_KEY = "transport"; const SESSION_META_KEY = "session-meta"; const LAST_ACTIVITY_KEY = "last-activity-ms"; const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`; -const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; -const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; - -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{ - readonly organizationId: string; -}> {} // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- +// The DO's JSON-RPC error bodies are INNER responses (no CORS): the edge worker +// re-wraps them with CORS before they leave the origin, so the canonical +// renderer is called with `cors: false` to stay byte-identical to the prior +// hand-rolled copy (`content-type: application/json` only). const jsonRpcError = (status: number, code: number, message: string) => - new Response(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }), { - status, - headers: { "content-type": "application/json" }, - }); + jsonRpcErrorBody(status, code, message, { cors: false }); const sessionOwnerMismatch = () => jsonRpcError(403, -32003, "MCP session does not belong to the current bearer"); -// W3C propagation across the worker→DO boundary. mcp.ts injects the worker's -// `traceparent` and forwards incoming `tracestate` / `baggage` headers on -// forwarded requests (and as a second arg to `init()`). We parse the context -// here and use `OtelTracer.withSpanContext` to stitch the DO's root span -// under the worker span so the entire logical request lives in one trace. -const TRACEPARENT_PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/; - -type IncomingSpanContext = { - readonly traceId: string; - readonly spanId: string; - readonly traceFlags: number; - readonly traceState?: ReturnType; -}; - -const parseTraceparent = ( - traceparent: string | null | undefined, - tracestate: string | null | undefined, -): IncomingSpanContext | null => { - const value = traceparent; - if (!value) return null; - const match = TRACEPARENT_PATTERN.exec(value); - if (!match) return null; - return { - traceId: match[2]!, - spanId: match[3]!, - traceFlags: parseInt(match[4]!, 16), - ...(tracestate ? { traceState: createTraceState(tracestate) } : {}), - }; -}; +// --------------------------------------------------------------------------- +// Host seams +// --------------------------------------------------------------------------- -const withIncomingParent = ( - incoming: IncomingTraceHeaders | null | undefined, - effect: Effect.Effect, -): Effect.Effect => { - const parsed = parseTraceparent(incoming?.traceparent, incoming?.tracestate); - return parsed ? OtelTracer.withSpanContext(effect, parsed) : effect; -}; +/** + * A host's per-session DB handle. The base only disposes it during runtime + * teardown; the host's `buildMcpServer` reads its concrete shape (postgres.js + * for cloud, the D1 `ExecutorDbHandle` for host-cloudflare). + */ +export interface SessionDbHandle { + readonly end: () => Promise | void; +} -type DbHandle = DbServiceShape & { readonly sql: Sql; end: () => Promise }; -type SessionMeta = { +/** + * Resolved session identity + elicitation mode — the output of a host's + * `resolveSessionMeta`. Persisted to `ctx.storage` so a cold isolate can + * re-validate ownership and rebuild the runtime without re-resolving. + */ +export interface SessionMeta { readonly organizationId: string; readonly organizationName: string; readonly userId: string; readonly elicitationMode?: "browser" | "model" | "native"; - readonly allowModelResume?: boolean; -}; - -/** - * Base DB handle factory for MCP session runtimes. - * - * The DO keeps one postgres.js client for the MCP session runtime. postgres.js - * closes idle sockets quickly, while the runtime object stays alive so the MCP - * server can preserve session-local protocol state across requests. - */ -const makeDbHandle = (options: { - readonly idleTimeout: number; - readonly maxLifetime: number; -}): DbHandle => { - const connectionString = resolveConnectionString(); - const sql = postgres(connectionString, { - max: 1, - idle_timeout: options.idleTimeout, - max_lifetime: options.maxLifetime, - connect_timeout: 10, - fetch_types: false, - prepare: true, - onnotice: () => undefined, - }); - return { - sql, - db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb, - // oxlint-disable-next-line executor/no-promise-catch -- boundary: postgres.js close is best-effort during DO/runtime cleanup - end: () => sql.end({ timeout: 0 }).catch(() => undefined), - }; -}; - -const makeLongLivedDb = (): DbHandle => - makeDbHandle({ - idleTimeout: LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS, - maxLifetime: LONG_LIVED_DB_MAX_LIFETIME_SECONDS, - }); - -const makeEphemeralDb = (): DbHandle => makeDbHandle({ idleTimeout: 0, maxLifetime: 60 }); + /** Public origin captured at session create — used to derive the runtime's + * web base URL when the host configures no static one. */ + readonly webOrigin?: string; +} -const makeResolveOrganizationServices = (dbHandle: DbHandle) => { - const DbLive = Layer.succeed(DbService)({ sql: dbHandle.sql, db: dbHandle.db }); - const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); - return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); -}; +/** What a host's `buildMcpServer` seam returns: the connected MCP server plus + * the engine the base drives for paused-execution approval flows. */ +export interface BuiltMcpServer { + readonly mcpServer: McpServer; + readonly engine: ExecutionEngine; +} -// Session services DON'T re-provide `DoTelemetryLive` — that would install a -// second WebSdk tracer in the nested Effect scope, disconnecting every -// child span from the outer `McpSessionDO.init` / `McpSessionDO.handleRequest` -// trace. Tracer comes from the outermost `Effect.provide(DoTelemetryLive)` -// at the DO method boundary. -const makeSessionServices = (dbHandle: DbHandle) => makeResolveOrganizationServices(dbHandle); - -const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* ( - organizationId: string, - userId: string, - elicitationMode: "browser" | "model" | "native", -) { - const org = yield* resolveOrganization(organizationId); - if (!org) { - return yield* new OrganizationNotFoundError({ organizationId }); - } - return { - organizationId: org.id, - organizationName: org.name, - userId, - elicitationMode, - } satisfies SessionMeta; -}); +/** The shared browser-approval store the base wires to its persisted approval + * responses; a host hands it to its MCP server when elicitation is "browser". */ +export interface BrowserApprovalStore { + readonly takeResponse: (executionId: string) => Effect.Effect; + readonly waitForResponse: (executionId: string) => Effect.Effect; +} // --------------------------------------------------------------------------- -// Durable Object +// Durable Object base // --------------------------------------------------------------------------- -export class McpSessionDO extends DurableObject { +export abstract class McpSessionDOBase< + TDbHandle extends SessionDbHandle = SessionDbHandle, +> extends DurableObject { private readonly instanceCreatedAt = Date.now(); private mcpServer: McpServer | null = null; private transport: McpWorkerTransport | null = null; private engine: ExecutionEngine | null = null; private initialized = false; private lastActivityMs = 0; - private dbHandle: DbHandle | null = null; + private dbHandle: TDbHandle | null = null; private sessionMeta: SessionMeta | null = null; private transportJsonResponseMode: boolean | null = null; private approvalResponses = new Map(); @@ -275,6 +184,64 @@ export class McpSessionDO extends DurableObject { // a per-session reference. private currentRequestSpan: Tracer.AnySpan | null = null; + // ------------------------------------------------------------------------- + // Host seams — the ONLY platform-specific surface. A host subclass binds its + // DB driver, organization lookup, and MCP-server/engine construction; cloud + // adds telemetry + Sentry by overriding the two optional hooks. Everything + // else in this class is platform-generic. + // ------------------------------------------------------------------------- + + /** Open the per-session DB handle the runtime holds for this session's + * lifetime (postgres.js for cloud, the D1 handle for host-cloudflare). May be + * async — host-cloudflare runs an idempotent schema bring-up when it opens. */ + protected abstract openSessionDb(): TDbHandle | Promise; + + /** Resolve `openSessionDb` (sync or async) into the Effect chain. */ + private openSessionDbHandle(): Effect.Effect { + return Effect.promise(() => Promise.resolve(this.openSessionDb())); + } + + /** Resolve + validate the session owner into the meta persisted to storage. + * Owns its own short-lived DB/services (it runs once per session create). */ + protected abstract resolveSessionMeta(token: McpSessionInit): Effect.Effect; + + /** Build the connected MCP server + engine for a resolved session. The host + * provides its execution stack + DB layers here; the base owns the transport + * and the per-request span / browser-approval wiring exposed below. */ + protected abstract buildMcpServer( + sessionMeta: SessionMeta, + dbHandle: TDbHandle, + ): Effect.Effect; + + /** Optional telemetry seam: stitch the DO span under the worker's incoming + * trace and install the host's tracer. Default is identity (no telemetry). */ + protected withTelemetry( + effect: Effect.Effect, + _incoming?: IncomingTraceHeaders, + ): Effect.Effect { + return effect; + } + + /** Optional error seam: report a fatal request cause (cloud → Sentry). */ + protected captureCause(_cause: Cause.Cause): void {} + + /** The session id — equal to this DO's id. */ + protected get sessionId(): string { + return this.ctx.id.toString(); + } + + /** The request-scoped span for the host-mcp `parentSpan` getter (read by + * deferred MCP SDK callbacks after the request Effect has returned). */ + protected currentParentSpan(): Tracer.AnySpan | undefined { + return this.currentRequestSpan ?? undefined; + } + + /** The browser-approval store wired to this session's persisted responses. */ + protected readonly browserApprovalStore: BrowserApprovalStore = { + takeResponse: (executionId) => this.takeApprovalResponse(executionId), + waitForResponse: (executionId) => this.waitForApprovalResponse(executionId), + }; + private makeStorage() { return { get: async (): Promise => { @@ -349,56 +316,28 @@ export class McpSessionDO extends DurableObject { private createConnectedRuntime( sessionMeta: SessionMeta, - options: { readonly dbHandle: DbHandle; readonly enableJsonResponse?: boolean }, + options: { readonly dbHandle: TDbHandle; readonly enableJsonResponse?: boolean }, ) { const self = this; return Effect.gen(function* () { - const { executor, engine } = yield* makeExecutionStack( - sessionMeta.userId, - sessionMeta.organizationId, - sessionMeta.organizationName, - ); - // Build the description here so the postgres query it runs - // (`executor.sources.list`) lands as a child of - // `McpSessionDO.createRuntime`. host-mcp would otherwise call - // `Effect.runPromise(engine.getDescription)` at its async - // MCP-SDK boundary and orphan the sub-span. - const description = yield* buildExecuteDescription(executor); - const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; - const mcpServer = yield* createExecutorMcpServer({ - engine, - description, - parentSpan: () => self.currentRequestSpan ?? undefined, - debug: env.EXECUTOR_MCP_DEBUG === "true", - browserApprovalStore: { - takeResponse: (executionId) => self.takeApprovalResponse(executionId), - waitForResponse: (executionId) => self.waitForApprovalResponse(executionId), - }, - elicitationMode: - sessionElicitationMode === "browser" - ? { - mode: "browser" as const, - approvalUrl: (executionId) => { - const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"; - const url = new URL(`/resume/${encodeURIComponent(executionId)}`, origin); - url.searchParams.set("mcp_session_id", self.ctx.id.toString()); - return url.toString(); - }, - } - : { mode: sessionElicitationMode }, - }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer")); + // The host builds its MCP server + engine (execution stack, DB layers, + // elicitation policy); the base owns the worker transport so JSON-response + // mode, the session-id generator, and storage stay identical everywhere. + // The session's captured origin is provided here so the host's execution + // stack derives a web base URL zero-config (a no-op when it configures one). + const built = self.buildMcpServer(sessionMeta, options.dbHandle); + const { mcpServer, engine } = yield* sessionMeta.webOrigin + ? built.pipe(Effect.provideService(RequestWebOrigin, { origin: sessionMeta.webOrigin })) + : built; const transport = yield* makeMcpWorkerTransport({ - sessionIdGenerator: () => self.ctx.id.toString(), + sessionIdGenerator: () => self.sessionId, storage: self.makeStorage(), enableJsonResponse: options.enableJsonResponse, }); self.transportJsonResponseMode = options.enableJsonResponse ?? false; yield* transport.connect(mcpServer); return { mcpServer, transport, engine }; - }).pipe( - Effect.withSpan("McpSessionDO.createRuntime"), - Effect.provide(makeSessionServices(options.dbHandle)), - ); + }).pipe(Effect.withSpan("McpSessionDO.createRuntime")); } private closeRuntime(): Effect.Effect { @@ -417,7 +356,7 @@ export class McpSessionDO extends DurableObject { self.engine = null; if (self.dbHandle) { const dbHandle = self.dbHandle; - yield* Effect.promise(() => dbHandle.end()); + yield* Effect.promise(() => Promise.resolve(dbHandle.end())); self.dbHandle = null; } self.initialized = false; @@ -431,7 +370,7 @@ export class McpSessionDO extends DurableObject { private installRuntime( sessionMeta: SessionMeta, options: { - readonly dbHandle: DbHandle; + readonly dbHandle: TDbHandle; readonly enableJsonResponse: boolean; }, ) { @@ -455,7 +394,7 @@ export class McpSessionDO extends DurableObject { if (!sessionMeta) return false; yield* self.closeRuntime(); - const dbHandle = makeLongLivedDb(); + const dbHandle = yield* self.openSessionDbHandle(); yield* self.installRuntime(sessionMeta, { dbHandle, enableJsonResponse: true, @@ -472,7 +411,7 @@ export class McpSessionDO extends DurableObject { } private validateApprovalIdentity( - identity: McpSessionApprovalIdentity, + identity: McpApprovalOwner, ): Effect.Effect<"ok" | "not_found" | "forbidden"> { const self = this; return Effect.gen(function* () { @@ -505,7 +444,7 @@ export class McpSessionDO extends DurableObject { } yield* self.closeRuntime(); - const dbHandle = makeLongLivedDb(); + const dbHandle = yield* self.openSessionDbHandle(); yield* self.installRuntime(sessionMeta, { dbHandle, // GET always returns an SSE stream regardless of this option, but the @@ -541,7 +480,7 @@ export class McpSessionDO extends DurableObject { if (!sessionMeta) return; yield* self.closeRuntime(); - const dbHandle = makeLongLivedDb(); + const dbHandle = yield* self.openSessionDbHandle(); yield* self.installRuntime(sessionMeta, { dbHandle, enableJsonResponse: true, @@ -578,20 +517,17 @@ export class McpSessionDO extends DurableObject { private resolveAndStoreSessionMeta(token: McpSessionInit) { const self = this; return Effect.gen(function* () { - const dbHandle = makeEphemeralDb(); - return yield* resolveSessionMeta( - token.organizationId, - token.userId, - token.elicitationMode ?? "model", - ).pipe( - Effect.provide(makeResolveOrganizationServices(dbHandle)), - Effect.tap((sessionMeta) => - Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( - Effect.withSpan("mcp.session.save_meta"), - ), - ), - Effect.ensuring(Effect.promise(() => dbHandle.end())), + const resolved = yield* self.resolveSessionMeta(token); + // Carry the create request's origin onto the persisted meta (the host's + // resolveSessionMeta is identity-only and doesn't see it), so a cold + // isolate rebuilds the runtime with the same web base URL. + const sessionMeta: SessionMeta = token.webOrigin + ? { ...resolved, webOrigin: token.webOrigin } + : resolved; + yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( + Effect.withSpan("mcp.session.save_meta"), ); + return sessionMeta; }).pipe(Effect.withSpan("mcp.session.resolve_and_store_meta")); } @@ -607,8 +543,7 @@ export class McpSessionDO extends DurableObject { Effect.withSpan("McpSessionDO.init", { attributes: { "mcp.auth.organization_id": token.organizationId }, }), - (eff) => withIncomingParent(incoming, eff), - Effect.provide(DoTelemetryLive), + (eff) => this.withTelemetry(eff, incoming), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object init method can only reject its Promise Effect.orDie, ), @@ -627,7 +562,7 @@ export class McpSessionDO extends DurableObject { return Effect.gen(function* () { const sessionMeta = yield* self.resolveAndStoreSessionMeta(token); - self.dbHandle = makeLongLivedDb(); + self.dbHandle = yield* self.openSessionDbHandle(); // POST responses go out as JSON so `transport.handleRequest()` awaits // every MCP tool callback before resolving — keeps engine spans inside // the outer `handleRequest` Effect's fiber so `currentRequestSpan` is @@ -708,15 +643,14 @@ export class McpSessionDO extends DurableObject { "mcp.request.session_id_present": !!request.headers.get("mcp-session-id"), }, }), - (eff) => withIncomingParent(incoming, eff), - Effect.provide(DoTelemetryLive), + (eff) => this.withTelemetry(eff, incoming), ); return Effect.runPromise(program); } async getPausedExecutionForApproval( executionId: string, - identity: McpSessionApprovalIdentity, + identity: McpApprovalOwner, incoming?: IncomingTraceHeaders, ): Promise { const self = this; @@ -741,8 +675,7 @@ export class McpSessionDO extends DurableObject { Effect.withSpan("McpSessionDO.getPausedExecutionForApproval", { attributes: { "mcp.execution.id": executionId }, }), - (eff) => withIncomingParent(incoming, eff), - Effect.provide(DoTelemetryLive), + (eff) => this.withTelemetry(eff, incoming), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: DO RPC exposes Promise results Effect.orDie, ), @@ -789,7 +722,7 @@ export class McpSessionDO extends DurableObject { async resumeExecutionForApproval( executionId: string, - identity: McpSessionApprovalIdentity, + identity: McpApprovalOwner, response: ResumeResponse, incoming?: IncomingTraceHeaders, ): Promise { @@ -816,8 +749,7 @@ export class McpSessionDO extends DurableObject { Effect.withSpan("McpSessionDO.resumeExecutionForApproval", { attributes: { "mcp.execution.id": executionId }, }), - (eff) => withIncomingParent(incoming, eff), - Effect.provide(DoTelemetryLive), + (eff) => this.withTelemetry(eff, incoming), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: DO RPC exposes Promise results Effect.orDie, ), @@ -884,17 +816,17 @@ export class McpSessionDO extends DurableObject { Effect.catchCause((cause) => Effect.sync(() => { console.error("[mcp-session] handleRequest error:", Cause.pretty(cause)); - captureCause(cause); + self.captureCause(cause); return jsonRpcError(500, -32603, "Internal error"); }), ), ); } - async alarm(): Promise { + override async alarm(): Promise { const program = Effect.promise(() => this.runAlarm()).pipe( Effect.withSpan("McpSessionDO.alarm"), - Effect.provide(DoTelemetryLive), + (eff) => this.withTelemetry(eff), ); return Effect.runPromise(program); } @@ -903,8 +835,7 @@ export class McpSessionDO extends DurableObject { return Effect.runPromise( Effect.promise(() => this.cleanup()).pipe( Effect.withSpan("McpSessionDO.clearSession"), - (eff) => withIncomingParent(incoming, eff), - Effect.provide(DoTelemetryLive), + (eff) => this.withTelemetry(eff, incoming), ), ); } diff --git a/packages/hosts/cloudflare/src/mcp/session-store.ts b/packages/hosts/cloudflare/src/mcp/session-store.ts new file mode 100644 index 000000000..c2f29f8ac --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/session-store.ts @@ -0,0 +1,173 @@ +// --------------------------------------------------------------------------- +// The Durable-Object-backed McpSessionStore — the cross-isolate variant of the +// shared host-mcp session seam. Shared by every Cloudflare host (cloud + +// host-cloudflare); a host supplies ONLY its DO namespace accessors (newStub +// for create, getStub for forward, addressed by the session-id == DO-id) and an +// optional internal-error reporter. Everything else — identity-header stamping, +// W3C trace propagation, response peeking, the verbatim DO error passthrough — +// is platform-generic. +// +// `dispatch` owns the worker-isolate orchestration: +// - sessionId null + POST initialize -> newStub() -> init(meta) + handleRequest +// - sessionId present -> getStub(id) -> handleRequest (the DO id routes back to +// the same isolate, which is the whole point — sessions survive across the +// worker's stateless isolates). +// +// IMPORTANT: the DO `Response` is returned VERBATIM (incl. its 403 -32003 / +// 404 -32001 error bodies) — the envelope's "forbidden"/"not-found" discriminants +// would emit different message bytes. The envelope short-circuits bare GET (400) +// and DELETE (204) before dispatch, so dispatch only sees create or forward. +// --------------------------------------------------------------------------- + +import { Effect, Layer } from "effect"; + +import { + McpSessionStore, + type McpDispatchInput, + type McpDispatchResult, +} from "@executor-js/host-mcp"; + +import { + currentPropagationHeaders, + readElicitationMode, + withMcpResponseHeaders, + withPropagationHeaders, + withVerifiedIdentityHeaders, + type VerifiedTokenHeaders, +} from "./do-headers"; +import { peekAndAnnotate, type OnInternalJsonRpcError } from "./response-peek"; +import type { McpSessionDOStub } from "./seams"; + +export type { McpSessionDOStub, McpSessionInit } from "./seams"; + +export interface DurableObjectStoreConfig { + /** Resolve the stub for an existing session id (the id IS the DO id). */ + readonly getStub: (sessionId: string) => McpSessionDOStub; + /** Mint a fresh session DO stub (a new unique id) for a create. */ + readonly newStub: () => McpSessionDOStub; + /** Observe a JSON-RPC -32603 the peeker surfaces (cloud: Sentry). */ + readonly onInternalError?: OnInternalJsonRpcError; +} + +/** + * Forward a request to an existing session DO. `peek` tees the body for + * telemetry on POST/DELETE; GET (SSE) streams through untouched. Returns the DO + * `Response` verbatim (incl. its 403 -32003 / 404 -32001 error bodies). + */ +const forwardToExistingSession = ( + config: DurableObjectStoreConfig, + request: Request, + sessionId: string, + peek: boolean, + token: VerifiedTokenHeaders, +): Effect.Effect => + Effect.gen(function* () { + const stub = config.getStub(sessionId); + const propagation = yield* currentPropagationHeaders(request); + const propagated = withPropagationHeaders( + withVerifiedIdentityHeaders(request, token), + propagation, + ); + const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe( + Effect.withSpan("mcp.do.handle_request", { + attributes: { + "mcp.request.method": request.method, + "mcp.request.session_id_present": true, + }, + }), + ); + const annotated = peek + ? yield* peekAndAnnotate(raw, { onInternalError: config.onInternalError }) + : raw; + return withMcpResponseHeaders(annotated); + }); + +/** Open a new session DO (POST, no session-id): init then handleRequest. */ +const createSession = ( + config: DurableObjectStoreConfig, + request: Request, + token: VerifiedTokenHeaders, +): Effect.Effect => + Effect.gen(function* () { + const stub = config.newStub(); + const propagation = yield* currentPropagationHeaders(request); + yield* Effect.promise(() => + stub.init( + { + organizationId: token.organizationId, + userId: token.accountId, + elicitationMode: readElicitationMode(request), + // The public origin the client reached us at — lets the DO derive a web + // base URL with no static config (we read the real URL, not a spoofable + // forwarded host). + webOrigin: new URL(request.url).origin, + }, + propagation, + ), + ).pipe( + Effect.withSpan("mcp.do.init", { + attributes: { "mcp.request.session_id_present": false }, + }), + ); + const propagated = withPropagationHeaders( + withVerifiedIdentityHeaders(request, token), + propagation, + ); + const raw = yield* Effect.promise(() => stub.handleRequest(propagated)).pipe( + Effect.withSpan("mcp.do.handle_request", { + attributes: { + "mcp.request.method": request.method, + "mcp.request.session_id_present": false, + }, + }), + ); + const annotated = yield* peekAndAnnotate(raw, { onInternalError: config.onInternalError }); + return withMcpResponseHeaders(annotated); + }); + +const clearExistingSession = ( + config: DurableObjectStoreConfig, + sessionId: string, + request?: Request, +): Effect.Effect => + Effect.gen(function* () { + const stub = config.getStub(sessionId); + // Disposal carries the active request's trace context (tracestate/baggage) + // when the envelope forwards the inbound request (the Forbidden-with-session + // teardown); otherwise a synthetic request, with traceparent still linking + // the span via the active Effect span. + const propagation = yield* currentPropagationHeaders( + request ?? new Request("https://mcp.invalid/mcp"), + ); + yield* Effect.promise(() => stub.clearSession(propagation)).pipe( + Effect.catchCause(() => Effect.void), + Effect.withSpan("mcp.do.clear_session", { + attributes: { "mcp.request.session_id_present": true }, + }), + ); + }); + +/** + * Build the `McpSessionStore` seam over a host's DO namespace. Cloud and + * host-cloudflare each pass their `getStub`/`newStub` (over `env.MCP_SESSION`); + * the dispatch logic is identical. + */ +export const makeDurableObjectMcpSessionStore = ( + config: DurableObjectStoreConfig, +): Layer.Layer => + Layer.succeed(McpSessionStore)({ + dispatch: ({ + request, + principal, + sessionId, + }: McpDispatchInput): Effect.Effect => { + const token: VerifiedTokenHeaders = { + accountId: principal.accountId, + organizationId: principal.organizationId, + }; + return sessionId + ? forwardToExistingSession(config, request, sessionId, request.method !== "GET", token) + : createSession(config, request, token); + }, + dispose: (sessionId, request) => clearExistingSession(config, sessionId, request), + }); diff --git a/apps/cloud/src/services/mcp-worker-transport.ts b/packages/hosts/cloudflare/src/mcp/worker-transport.ts similarity index 100% rename from apps/cloud/src/services/mcp-worker-transport.ts rename to packages/hosts/cloudflare/src/mcp/worker-transport.ts diff --git a/packages/hosts/cloudflare/tsconfig.json b/packages/hosts/cloudflare/tsconfig.json new file mode 100644 index 000000000..ccb16ed0e --- /dev/null +++ b/packages/hosts/cloudflare/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["@cloudflare/workers-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": { + "preferSchemaOverJson": "off" + } + } + ] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/hosts/cloudflare/vitest.config.ts b/packages/hosts/cloudflare/vitest.config.ts new file mode 100644 index 000000000..5bfa2d586 --- /dev/null +++ b/packages/hosts/cloudflare/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + passWithNoTests: true, + }, +}); diff --git a/packages/hosts/mcp/package.json b/packages/hosts/mcp/package.json index 296bfa87a..3b0364318 100644 --- a/packages/hosts/mcp/package.json +++ b/packages/hosts/mcp/package.json @@ -4,7 +4,18 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./tool-server": { + "types": "./src/tool-server.ts", + "default": "./src/tool-server.ts" + }, + "./in-memory-session-store": { + "types": "./src/in-memory-session-store.ts", + "default": "./src/in-memory-session-store.ts" + } }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts new file mode 100644 index 000000000..ea4898416 --- /dev/null +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -0,0 +1,135 @@ +// --------------------------------------------------------------------------- +// Envelope regression tests — lock in the streamable-HTTP contract the shared +// `McpServingRoutes` must preserve, independent of any provider: +// +// 1. A method the transport doesn't serve (PUT/PATCH/…) -> 405 -32001. +// 2. An OPTIONS preflight on a provider-declared discovery path -> 204 + CORS. +// 3. A request-orchestration defect -> 500 -32603 + the McpErrorReporter fires. +// +// Built with minimal stub seams so the assertions target the envelope alone. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Layer, Ref } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; + +import { + authenticated, + McpAuthProvider, + McpErrorReporter, + McpErrorReporterNoop, + McpServingRoutes, + McpSessionStore, + type McpDispatchResult, + type Principal, +} from "./index"; + +const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; + +const TEST_PRINCIPAL: Principal = { + accountId: "acct_test", + organizationId: "org_test", + organizationName: "Test Org", + email: "test@example.com", + name: "Test", + avatarUrl: null, + roles: ["user"], +}; + +/** An auth provider that authenticates everything (so dispatch is reached). */ +const AuthProviderLive = Layer.succeed(McpAuthProvider)({ + discoveryRoutes: [ + { + path: DISCOVERY_PATH, + handler: () => Effect.succeed(new Response(JSON.stringify({ ok: true }), { status: 200 })), + }, + ], + resourceMetadataUrl: (request) => `${new URL(request.url).origin}${DISCOVERY_PATH}`, + authenticate: () => Effect.succeed(authenticated(TEST_PRINCIPAL)), +}); + +/** A store whose dispatch dies — induces the orchestration defect for case 3. */ +const DefectStoreLive = Layer.succeed(McpSessionStore)({ + dispatch: (): Effect.Effect => Effect.die("induced defect"), + dispose: () => Effect.void, +}); + +/** A store whose dispatch never runs — used for the 405 case (rejected first). */ +const OkStoreLive = Layer.succeed(McpSessionStore)({ + dispatch: (): Effect.Effect => + Effect.succeed(new Response(JSON.stringify({ jsonrpc: "2.0", id: 1 }), { status: 200 })), + dispose: () => Effect.void, +}); + +const buildHandler = ( + store: Layer.Layer, + reporter: Layer.Layer, +): ((request: Request) => Promise) => { + const Seams = Layer.mergeAll(AuthProviderLive, store, reporter); + const RouteLive = McpServingRoutes.pipe( + HttpRouter.provideRequest(Seams), + Layer.provide(AuthProviderLive), + ); + return HttpRouter.toWebHandler(RouteLive.pipe(Layer.provideMerge(HttpServer.layerServices))) + .handler; +}; + +describe("McpServingRoutes envelope", () => { + it("rejects a non-GET/POST/DELETE/OPTIONS method with 405 -32001 before dispatch", async () => { + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); + for (const method of ["PUT", "PATCH"] as const) { + const response = await handler( + new Request("https://host.test/mcp", { + method, + headers: { authorization: "Bearer x", "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + expect(response.status, `${method} should be 405`).toBe(405); + const body = (await response.json()) as { error: { code: number; message: string } }; + expect(body.error.code).toBe(-32001); + expect(body.error.message).toMatch(/method not allowed/i); + } + }); + + it("answers an OPTIONS preflight on a discovery path with 204 + CORS", async () => { + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); + const response = await handler( + new Request(`https://host.test${DISCOVERY_PATH}`, { + method: "OPTIONS", + headers: { origin: "https://claude.ai", "access-control-request-method": "GET" }, + }), + ); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS"); + expect(response.headers.get("access-control-allow-headers") ?? "").toContain("authorization"); + }); + + it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => { + const reported = await Effect.runPromise(Ref.make>([])); + const RecordingReporter = Layer.succeed(McpErrorReporter)({ + report: (cause: Cause.Cause) => + Ref.update(reported, (acc) => [...acc, Cause.pretty(cause)]), + }); + + const handler = buildHandler(DefectStoreLive, RecordingReporter); + const response = await handler( + new Request("https://host.test/mcp", { + method: "POST", + headers: { authorization: "Bearer x", "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + + expect(response.status).toBe(500); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + const body = (await response.json()) as { error: { code: number; message: string } }; + expect(body.error.code).toBe(-32603); + expect(body.error.message).toMatch(/internal server error/i); + + const captures = await Effect.runPromise(Ref.get(reported)); + expect(captures).toHaveLength(1); + expect(captures[0]).toContain("induced defect"); + }); +}); diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts new file mode 100644 index 000000000..bed9070ff --- /dev/null +++ b/packages/hosts/mcp/src/envelope.ts @@ -0,0 +1,278 @@ +import { Effect, Match, Predicate } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { + McpAuthProvider, + McpErrorReporter, + McpSessionStore, + type AuthOutcome, + type McpDispatchResult, +} from "./seams"; + +// --------------------------------------------------------------------------- +// Provider-neutral MCP serving envelope. +// +// Routes: +// GET -> McpAuthProvider metadata +// * /mcp -> authenticate -> dispatch +// +// The provider DECLARES the discovery paths it owns (at least the protected- +// resource metadata document) via `McpAuthProvider.discoveryRoutes`; the +// envelope never hard-codes `/.well-known/oauth-*`. The OAuth endpoints +// (/authorize, /token, /register) stay OUT of the envelope: they are served by +// the provider's own handler (self-host: Better Auth at /api/auth; cloud: +// WorkOS, external). The envelope only needs the provider's discovery routes, +// resource-metadata URL, and authenticate. +// +// The envelope hard-codes ONLY the `/mcp` path and CORS. Everything else — +// every `/.well-known/*` path, the resource-metadata URL, the authn/authz +// semantics, and the entire session lifecycle (create + forward + ownership) — +// comes from the two seams. +// +// Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO +// platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport +// works on web `Request`/`Response`, so the envelope reconstructs the inbound +// web request once, hands it to the store, and wraps the store's `Response` +// with `HttpServerResponse.raw` (which passes a `Response` body through +// unchanged, preserving streaming SSE bodies). +// --------------------------------------------------------------------------- + +const MCP_PATH = "/mcp"; + +/** The methods the streamable-HTTP transport accepts on `/mcp`. */ +const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); + +/** + * The canonical CORS preflight `Response` (204) answered for an `OPTIONS` on + * `/mcp` AND on every provider-declared discovery path. A browser issues a + * preflight against the metadata docs too (RFC 9728 discovery from a 401), so + * the envelope answers OPTIONS for those paths, not only `/mcp`. + */ +const corsPreflightResponse = (): Response => + new Response(null, { + status: 204, + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", + "access-control-allow-headers": + "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", + "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", + }, + }); + +/** + * The canonical JSON-RPC error `Response` builder for every MCP serving site. + * + * Emits the EXACT body every host renders — `{jsonrpc:"2.0",error:{code,message}, + * id:null}` — with `content-type: application/json`. Two header policies: + * + * - `cors: true` (default) adds `access-control-allow-origin: *`. This is the + * envelope's policy and the cloud edge worker's (`jsonRpcWebResponse`): + * errors cross the browser boundary, so they carry CORS. A `challenge` + * additionally emits the `WWW-Authenticate` header + exposes it via CORS + * (the 401 path). + * - `cors: false` omits CORS entirely — for INNER responses that never reach + * the browser directly (the cloud Durable Object and the self-host /local + * in-process stores, whose `Response` is post-processed / re-wrapped with + * CORS by the outer envelope before it leaves the origin). + * + * One renderer, byte-identical bodies across host-mcp + cloud + self-host + + * local — the four hand-rolled copies are deleted in favor of this. + */ +export const jsonRpcErrorBody = ( + status: number, + code: number, + message: string, + opts?: { readonly cors?: boolean; readonly challenge?: string }, +): Response => { + const cors = opts?.cors ?? true; + const challenge = opts?.challenge; + return new Response(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }), { + status, + headers: { + "content-type": "application/json", + ...(cors ? { "access-control-allow-origin": "*" } : {}), + ...(challenge + ? { + "www-authenticate": challenge, + "access-control-expose-headers": "WWW-Authenticate", + } + : {}), + }, + }); +}; + +/** The envelope's own CORS-on JSON-RPC error `Response`, optionally carrying a challenge. */ +const jsonRpcResponse = ( + status: number, + code: number, + message: string, + challenge?: string, +): Response => + challenge === undefined + ? jsonRpcErrorBody(status, code, message) + : jsonRpcErrorBody(status, code, message, { challenge }); + +/** + * Reconstruct a WHATWG `Request` from the Effect HTTP request. Prefer the + * underlying source `Request` (preserves the body stream the transport reads); + * otherwise rebuild from parts. A failed body read is a defect here, not a + * recoverable error. + */ +const toWebRequest = (req: HttpServerRequest.HttpServerRequest): Effect.Effect => + Effect.gen(function* () { + if (req.source instanceof Request) return req.source; + const headers = new Headers(req.headers as Record); + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: rebuilding a web Request from a non-web source; a failed body read is an unrecoverable infra defect, not a domain error + const body = hasBody ? yield* req.text.pipe(Effect.orDie) : undefined; + return new Request(req.url, { method: req.method, headers, body }); + }); + +/** Serve a provider discovery document, wrapping its web `Response`. */ +const discoveryRoute = (handler: (request: Request) => Effect.Effect) => + Effect.gen(function* () { + const httpRequest = yield* HttpServerRequest.HttpServerRequest; + const request = yield* toWebRequest(httpRequest); + const response = yield* handler(request); + return HttpServerResponse.raw(response); + }); + +/** + * Render a non-`Authenticated` {@link AuthOutcome} to a web `Response`: + * Unauthorized -> 401 + RFC 9728 challenge (outcome's own, else a default + * built from the provider's `resourceMetadataUrl`) + * Forbidden -> 403 JSON-RPC (default code -32001) + * Unavailable -> 503 JSON-RPC -32001 + */ +const renderAuthError = ( + auth: McpAuthProvider["Service"], + request: Request, + outcome: Exclude, +): Response => + Match.value(outcome).pipe( + Match.tag("Unauthorized", (u) => + jsonRpcResponse( + 401, + -32001, + "Unauthorized", + u.challenge ?? `Bearer resource_metadata="${auth.resourceMetadataUrl(request)}"`, + ), + ), + Match.tag("Forbidden", (f) => jsonRpcResponse(403, f.code ?? -32001, f.message)), + Match.tag("Unavailable", (u) => jsonRpcResponse(503, -32001, u.message)), + Match.exhaustive, + ); + +/** Render a non-`Response` {@link McpDispatchResult} discriminant. */ +const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => + lookup === "not-found" + ? jsonRpcResponse(404, -32001, "Session not found") + : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); + +/** Dispatch a `/mcp` request through authenticate -> store.dispatch -> transport. */ +const mcpDispatch = Effect.gen(function* () { + const httpRequest = yield* HttpServerRequest.HttpServerRequest; + const auth = yield* McpAuthProvider; + const store = yield* McpSessionStore; + const request = yield* toWebRequest(httpRequest); + + // CORS preflight: answer before auth so unauthenticated clients can probe. + if (request.method === "OPTIONS") { + return HttpServerResponse.raw(corsPreflightResponse()); + } + + // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other + // method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch — + // otherwise it would fall through and spin up a session engine for a method + // the transport can't serve. + if (!ALLOWED_MCP_METHODS.has(request.method)) { + return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed")); + } + + const sessionId = request.headers.get("mcp-session-id"); + + // Authenticate (and, for session-aware providers, authorize) on EVERY + // request. On a non-Authenticated outcome: + // - Forbidden -> dispose the live session first (cloud tears down a DO + // whose org access was revoked), then render the 403. The + // inbound request is forwarded so the store can propagate + // the request's W3C trace context onto the teardown RPC. + // - other -> render directly. + const outcome = yield* auth.authenticate(request); + if (!Predicate.isTagged(outcome, "Authenticated")) { + if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { + yield* store.dispose(sessionId, request); + } + return HttpServerResponse.raw(renderAuthError(auth, request, outcome)); + } + const principal = outcome.principal; + + // No session id: per the streamable-HTTP transport contract, only POST opens + // a session. A GET needs an existing id (400); a DELETE on nothing is a + // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up + // an engine for a bare GET/DELETE. + if (!sessionId) { + if (request.method === "GET") { + return HttpServerResponse.raw( + jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"), + ); + } + if (request.method === "DELETE") { + return HttpServerResponse.raw( + new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }), + ); + } + } + + const result: McpDispatchResult = yield* store.dispatch({ + request, + principal, + sessionId, + method: request.method, + }); + return HttpServerResponse.raw(result instanceof Response ? result : renderDispatchError(result)); +}); + +/** + * The `/mcp` route. Wraps {@link mcpDispatch} in a top-level `catchCause`: a + * request-orchestration defect (a rejected cross-isolate RPC, a body-tee + * failure, …) is reported to the optional {@link McpErrorReporter} (Sentry / + * `ErrorCapture` parity — the provider's capture pipeline would never see it + * otherwise, since the envelope returns a `Response`) and rendered as a stable + * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. + */ +const mcpRoute = mcpDispatch.pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const reporter = yield* McpErrorReporter; + yield* reporter.report(cause); + return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error")); + }), + ), +); + +/** + * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges + * this with its other routes and provides the two seam Layers + the HTTP + * platform services. Provider-neutral: cloud adopts the same Layer next. + * + * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so + * the provider — not the envelope — owns its `/.well-known/oauth-*` paths. An + * `OPTIONS` on each discovery path answers the same CORS preflight as `/mcp` + * (a browser preflights the metadata docs during RFC 9728 discovery). + */ +export const McpServingRoutes = HttpRouter.use((router) => + Effect.gen(function* () { + const auth = yield* McpAuthProvider; + for (const route of auth.discoveryRoutes) { + yield* router.add("GET", route.path, discoveryRoute(route.handler)); + yield* router.add( + "OPTIONS", + route.path, + Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())), + ); + } + yield* router.add("*", MCP_PATH, mcpRoute); + }), +); diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts new file mode 100644 index 000000000..d22f8a16a --- /dev/null +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -0,0 +1,187 @@ +import { Data, Effect, Layer } from "effect"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; + +import { jsonRpcErrorBody } from "./envelope"; +import { + McpSessionStore, + principalOwns, + type McpDispatchInput, + type McpDispatchResult, + type Principal, +} from "./seams"; + +// --------------------------------------------------------------------------- +// In-process McpSessionStore — the single-node serving store, shared by every +// host that has no cross-isolate session backend (self-host, the Cloudflare +// QuickJS host). Cloud's Durable Object store is the cross-isolate variant of +// the same `McpSessionStore` seam. +// +// In the two-seam envelope the store owns the ENTIRE session lifecycle via +// `dispatch`: create (no session id + POST initialize), forward (session id +// present), and ownership (cross-bearer). Three Maps keyed by mcp-session-id — +// transports, servers, owners — hold the live in-process sessions. Closing a +// session is just closing its transport + server. +// +// The engine is a store implementation detail, not an envelope seam: the store +// builds each per-session `McpServer` through the host-supplied `buildServer` +// (the host's execution stack over its own DB + code substrate). The two-seam +// envelope has no engine seam — the store owns engine construction. +// +// `dispatch` returns the transport `Response` to pass through, or: +// - "not-found" (unknown session id) -> envelope renders 404 -32001 +// - "forbidden" (session owned by another bearer) -> envelope renders 403 -32003 +// --------------------------------------------------------------------------- + +/** Engine construction failed for a principal. The store surfaces it as a 500. */ +export class McpEngineBuildError extends Data.TaggedError("McpEngineBuildError")<{ + readonly cause: unknown; +}> {} + +/** Build the per-session `McpServer` for a principal (the host's engine + tools). */ +export type McpBuildServer = ( + principal: Principal, +) => Effect.Effect; + +export interface InMemoryMcpSessionStore { + /** The `McpSessionStore` seam value to hand to `inMemoryMcpSessionsLayer`. */ + readonly store: McpSessionStore["Service"]; + /** Dispose every live session — wire into the host's shutdown (not a seam). */ + readonly close: () => Promise; +} + +const ignoreClose = (close: (() => Promise) | undefined): Promise => + close + ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined }))) + : Promise.resolve(); + +const formatBoundaryError = (error: unknown): unknown => + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: log unknown MCP SDK/runtime failures + error instanceof Error ? (error.stack ?? error.message) : error; + +// The store's error bodies are INNER responses (no CORS): the serving envelope +// re-wraps the store `Response` with CORS before it leaves the origin, so the +// canonical renderer is called with `cors: false` (content-type only). +const jsonRpcError = (status: number, code: number, message: string): Response => + jsonRpcErrorBody(status, code, message, { cors: false }); + +/** + * Build the in-process session store plus an explicit `close()` that disposes + * all live sessions. `close()` is not part of the seam — it is the host lifetime + * hook the envelope doesn't own. Each per-session engine comes from the + * host-supplied `buildServer`. + */ +export const makeInMemoryMcpSessionStore = ( + buildServer: McpBuildServer, +): InMemoryMcpSessionStore => { + const transports = new Map(); + const servers = new Map(); + const owners = new Map(); + + const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { + const transport = transports.get(id); + const server = servers.get(id); + transports.delete(id); + servers.delete(id); + owners.delete(id); + if (opts.transport) await ignoreClose(transport ? () => transport.close() : undefined); + if (opts.server) await ignoreClose(server ? () => server.close() : undefined); + }; + + /** + * Drive a transport for one web request, recovering any defect to a 500. On a + * fresh transport that never minted a session id (e.g. a non-initialize first + * request), close it and its server eagerly so they don't leak. + */ + const runHandleRequest = ( + transport: WebStandardStreamableHTTPServerTransport, + request: Request, + onClose?: () => void, + ): Effect.Effect => { + const finish = (): void => { + if (onClose && !transport.sessionId) onClose(); + }; + return Effect.promise(() => transport.handleRequest(request)).pipe( + Effect.tap(() => Effect.sync(finish)), + Effect.catchCause((cause) => + Effect.sync(() => { + console.error("[mcp] handleRequest error:", formatBoundaryError(cause)); + finish(); + return jsonRpcError(500, -32603, "Internal server error"); + }), + ), + ); + }; + + /** Forward to an existing session, enforcing ownership against the principal. */ + const forward = ( + sessionId: string, + principal: Principal, + request: Request, + ): Effect.Effect => { + const transport = transports.get(sessionId); + const owner = owners.get(sessionId); + if (!transport || !owner) return Effect.succeed("not-found"); + if (!principalOwns(owner, principal)) return Effect.succeed("forbidden"); + return runHandleRequest(transport, request); + }; + + /** Open a new session: build the server, connect a transport, drive the request. */ + const create = (principal: Principal, request: Request): Effect.Effect => + buildServer(principal).pipe( + Effect.flatMap((server) => + Effect.gen(function* () { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + onsessioninitialized: (sid) => { + transports.set(sid, transport); + servers.set(sid, server); + owners.set(sid, principal); + }, + onsessionclosed: (sid) => void dispose(sid, { server: true }), + }); + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) void dispose(sid, { server: true }); + }; + yield* Effect.promise(() => server.connect(transport)); + // The session id is minted on the first (initialize) request, so we + // drive `handleRequest` here; if no id results we close eagerly. + return yield* runHandleRequest(transport, request, () => { + void ignoreClose(() => transport.close()); + void ignoreClose(() => server.close()); + }); + }), + ), + // A build failure has nowhere typed to go in the envelope; render a 500. + Effect.catchTag("McpEngineBuildError", () => + Effect.succeed(jsonRpcError(500, -32603, "Internal server error")), + ), + ); + + const store: McpSessionStore["Service"] = { + dispatch: ({ request, principal, sessionId }: McpDispatchInput) => + sessionId ? forward(sessionId, principal, request) : create(principal, request), + dispose: (sessionId) => + Effect.promise(() => dispose(sessionId, { transport: true, server: true })), + }; + + return { + store, + close: async () => { + const ids = new Set([...transports.keys(), ...servers.keys()]); + await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); + }, + }; +}; + +/** + * Layer wrapping a freshly built in-process store, the `McpSessionStore` + * envelope seam. The owning app calls `makeInMemoryMcpSessionStore(buildServer)` + * directly so it can wire the `close()` lifetime hook into shutdown, then passes + * the built store here. + */ +export const inMemoryMcpSessionsLayer = ( + built: InMemoryMcpSessionStore, +): Layer.Layer => Layer.succeed(McpSessionStore)(built.store); diff --git a/packages/hosts/mcp/src/index.ts b/packages/hosts/mcp/src/index.ts index 2ad4b1e83..08d0e7d74 100644 --- a/packages/hosts/mcp/src/index.ts +++ b/packages/hosts/mcp/src/index.ts @@ -1 +1,36 @@ -export { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./server"; +// --------------------------------------------------------------------------- +// @executor-js/host-mcp — the provider-neutral MCP SERVING surface. +// +// This entry point exports ONLY the serving envelope (`McpServingRoutes`) + +// its seams (`McpAuthProvider` / `McpSessionStore` / `McpErrorReporter` / +// `Principal`) + the canonical JSON-RPC error renderer (`jsonRpcErrorBody`). +// +// The executor TOOL factory (`createExecutorMcpServer` — the execute/resume +// tools, the elicitation/browser-approval bridge, the Zod input schemas) is a +// different center of gravity: a host's session store builds an `McpServer` +// from it. It lives behind the `@executor-js/host-mcp/tool-server` subpath so +// the serving surface stays small and dependency-light. +// --------------------------------------------------------------------------- + +export { + Principal, + McpAuthProvider, + McpSessionStore, + McpErrorReporter, + McpErrorReporterNoop, + principalOwns, + authenticated, + unauthorized, + forbidden, + unavailable, + type AuthOutcome, + type McpAuthenticated, + type McpUnauthorized, + type McpForbidden, + type McpUnavailable, + type McpDiscoveryRoute, + type McpDispatchInput, + type McpDispatchResult, +} from "./seams"; + +export { McpServingRoutes, jsonRpcErrorBody } from "./envelope"; diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts new file mode 100644 index 000000000..5c82295da --- /dev/null +++ b/packages/hosts/mcp/src/seams.ts @@ -0,0 +1,279 @@ +import { Context, Effect, Layer, Schema } from "effect"; +import type { Cause } from "effect"; + +// --------------------------------------------------------------------------- +// Provider-neutral MCP serving seams. +// +// The shared MCP serving envelope (see `./envelope`) depends ONLY on these TWO +// seams. Each product (self-host, cloud, local) provides its own Layer +// satisfying the same tags; the envelope never changes. The seams are kept +// deliberately small — anything provider-specific (Durable-Object trace +// propagation, response-peeking, browser-approval stores, elicitation modes, +// per-org engine construction) is configured *inside* a provider's adapter and +// never baked into the envelope. +// +// Two seams, deliberately: +// 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize +// (it may read the `mcp-session-id` header to do session-aware org-authz). +// 2. McpSessionStore — owns the serving session lifecycle: create + forward + +// ownership, end to end, via a single `dispatch`. The store builds/forwards +// the transport and returns the transport `Response`. +// +// There is deliberately NO envelope-level engine seam. Self-host's in-process +// store builds its engine via an INTERNAL dependency (its Layer provides it); +// cloud's Durable-Object store builds its engine inside the DO. The engine is a +// store implementation detail, not an envelope seam. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Shared domain — the authenticated principal. +// +// One word per concept: this is the SAME authenticated-caller noun the +// executor-API runs on (`Principal` in `@executor-js/api/server`); the shapes +// are byte-identical so the Better Auth / WorkOS adapters map onto it without +// translation. host-mcp keeps its own Schema'd copy (it does not depend on +// `@executor-js/api`) so it remains the validated boundary between auth +// (provider) and serving (envelope). +// --------------------------------------------------------------------------- + +export const Principal = Schema.Struct({ + accountId: Schema.String, + organizationId: Schema.String, + organizationName: Schema.String, + email: Schema.String, + name: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + roles: Schema.Array(Schema.String), +}); + +export type Principal = Schema.Schema.Type; + +/** Ownership is keyed on (accountId, organizationId) — a subset of the principal. */ +export const principalOwns = (owner: Principal, principal: Principal): boolean => + owner.accountId === principal.accountId && owner.organizationId === principal.organizationId; + +// --------------------------------------------------------------------------- +// AuthOutcome — the result of `McpAuthProvider.authenticate`. +// +// A typed, never-failing discriminated union (NOT `Principal | null`, NOT an +// error channel) so a provider can distinguish the cases the envelope renders +// differently: +// +// Authenticated -> proceed to session dispatch +// Unauthorized -> 401 + RFC 9728 `WWW-Authenticate` challenge +// Forbidden -> 403 JSON-RPC error (cloud: "No organization in session …", +// default code -32001) — a VALID bearer that lacks the +// authorization the resource requires (e.g. no org). Because +// `authenticate` runs on EVERY request, a provider can return +// Forbidden on a reused session too; the envelope then +// disposes that session before rendering the 403. +// Unavailable -> 503 JSON-RPC error (cloud: "Authentication temporarily +// unavailable …") — a transient verification failure the +// client should retry. +// +// Plain tagged objects (consumed in-process by the envelope's `Match`), with +// constructors so providers never hand-roll the shape. `Principal` is the +// only field that is itself Schema-validated; the union does not cross a +// serialization boundary, so it stays a TS union rather than a decoded Schema. +// --------------------------------------------------------------------------- + +export interface McpAuthenticated { + readonly _tag: "Authenticated"; + readonly principal: Principal; +} + +export interface McpUnauthorized { + readonly _tag: "Unauthorized"; + /** + * The full `WWW-Authenticate: Bearer …` challenge value to emit on the 401. + * When omitted the envelope synthesizes a default from + * {@link McpAuthProvider.resourceMetadataUrl}. A provider that needs a + * reason-sensitive challenge (cloud: `missing_bearer` -> no `error=` param, + * `invalid_token` -> `error="invalid_token", error_description=…`) supplies + * the exact string here. + */ + readonly challenge?: string; +} + +export interface McpForbidden { + readonly _tag: "Forbidden"; + /** JSON-RPC error code; defaults to -32001 (cloud's no-org code). */ + readonly code?: number; + readonly message: string; +} + +export interface McpUnavailable { + readonly _tag: "Unavailable"; + readonly message: string; +} + +export type AuthOutcome = McpAuthenticated | McpUnauthorized | McpForbidden | McpUnavailable; + +export const authenticated = (principal: Principal): McpAuthenticated => ({ + _tag: "Authenticated", + principal, +}); + +export const unauthorized = (challenge?: string): McpUnauthorized => + challenge === undefined ? { _tag: "Unauthorized" } : { _tag: "Unauthorized", challenge }; + +export const forbidden = (message: string, code?: number): McpForbidden => + code === undefined ? { _tag: "Forbidden", message } : { _tag: "Forbidden", code, message }; + +export const unavailable = (message: string): McpUnavailable => ({ + _tag: "Unavailable", + message, +}); + +// =========================================================================== +// SEAM 1 — McpAuthProvider: OAuth metadata + per-request authn/authz + challenge. +// +// The envelope serves the provider-DECLARED `/.well-known/oauth-*` docs from +// here and calls `authenticate` on EVERY `/mcp` request (create, forward, +// GET, DELETE). The OAuth endpoints themselves (/authorize, /token, /register) +// are NOT part of this seam — they are served by the provider's own handler +// (self-host: Better Auth at /api/auth; cloud: WorkOS, external), because the +// envelope only needs discovery routes + authenticate + resource URL. +// =========================================================================== + +/** + * One provider-served discovery document the envelope mounts as `GET path`. + * The provider OWNS its paths (self-host serves the bare origin-root docs; + * cloud serves `/.well-known/oauth-protected-resource/mcp`), so the envelope + * never hard-codes them. + */ +export interface McpDiscoveryRoute { + /** Absolute path the envelope mounts as `GET path` (an `HttpRouter` PathInput). */ + readonly path: `/${string}`; + readonly handler: (request: Request) => Effect.Effect; +} + +export class McpAuthProvider extends Context.Service< + McpAuthProvider, + { + /** + * The discovery routes this provider serves (at minimum the protected- + * resource metadata document). The envelope registers a `GET` for each. + */ + readonly discoveryRoutes: ReadonlyArray; + /** + * The absolute `resource_metadata` URL clients should follow from a 401, + * derived from the request (so it carries the live origin). Used by the + * envelope ONLY to build a default challenge when an `Unauthorized` outcome + * does not carry its own `challenge` string. Self-host = + * bare `…/.well-known/oauth-protected-resource`; cloud = + * `…/.well-known/oauth-protected-resource/mcp`. + */ + readonly resourceMetadataUrl: (request: Request) => string; + /** + * Resolve a request to a typed {@link AuthOutcome}. Never fails: provider + * errors collapse into `Unauthorized`/`Unavailable` outcomes. + * + * Called on EVERY request, so the provider may read the `mcp-session-id` + * header to do session-aware org-authorization (cloud re-checks live org + * membership on reused sessions and returns `Forbidden` when revoked; the + * envelope then disposes the session). Self-host pins one org and never + * returns Forbidden/Unavailable. + * + * MUST enforce token expiry itself — Better Auth's `getMcpSession` does NOT + * validate `accessTokenExpiresAt`, so an expired token must resolve to + * `Unauthorized` here. + */ + readonly authenticate: (request: Request) => Effect.Effect; + } +>()("@executor-js/host-mcp/McpAuthProvider") {} + +// =========================================================================== +// SEAM 2 — McpSessionStore: the ENTIRE MCP serving-session lifecycle. +// +// `dispatch` owns create + forward + ownership end to end: +// - sessionId null + POST initialize -> build/forward, returns the transport +// `Response` (incl. the minted `mcp-session-id` header). +// - sessionId present -> reuse/forward the existing session's transport. +// - cross-bearer -> `"forbidden"` (403 -32003). +// - unknown / timed out -> `"not-found"` (404 -32001). +// +// The store receives the full inbound `Request` (so a cross-isolate forward can +// stream the body and inject identity/trace headers) and the `method` (so it can +// distinguish GET peek-vs-stream from POST/DELETE). It owns transport creation, +// `server.connect`, `handleRequest`, the session id, ownership, and lifetime. +// +// There is no envelope-level engine seam: the store builds its engine itself +// (self-host: an INTERNAL dependency the store's Layer provides; cloud: inside +// the DO). +// =========================================================================== + +export interface McpDispatchInput { + readonly request: Request; + readonly principal: Principal; + readonly sessionId: string | null; + readonly method: string; +} + +/** + * The result of `dispatch`. A `Response` is returned verbatim (SSE-safe); + * `"not-found"` and `"forbidden"` are DISTINCT discriminants the envelope maps + * to 404 -32001 and 403 -32003 respectively. + */ +export type McpDispatchResult = Response | "not-found" | "forbidden"; + +export class McpSessionStore extends Context.Service< + McpSessionStore, + { + /** + * Serve one `/mcp` request end to end. Owns create (no session id + POST + * initialize), forward (session id present), and ownership (cross-bearer -> + * `"forbidden"`). Returns the transport `Response` to pass through, or a + * `"not-found"` / `"forbidden"` discriminant for the envelope to render. + */ + readonly dispatch: (input: McpDispatchInput) => Effect.Effect; + /** + * Tear down a session by id (idempotent). + * + * `request` carries the inbound `Request` SO a cross-isolate store (cloud's + * Durable Object) can forward it and propagate the request's W3C trace + * context (tracestate/baggage) onto the disposal RPC, stitching the teardown + * into the same trace. A single-node store (self-host / local) IGNORES it — + * the dispose runs in-process and carries no inbound trace context — which is + * why it is optional. The envelope passes it on the Forbidden-with-session + * teardown (the only call site that has a live request). + */ + readonly dispose: (sessionId: string, request?: Request) => Effect.Effect; + } +>()("@executor-js/host-mcp/McpSessionStore") {} + +// =========================================================================== +// SEAM 3 (optional) — McpErrorReporter: observe a request-orchestration defect. +// +// The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and +// renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the +// raw defect; the client gets a stable error envelope + CORS). Because the +// envelope swallows the cause into a `Response`, a provider's existing error +// pipeline (cloud: Sentry `captureException`; self-host: `ErrorCapture`) would +// otherwise NEVER see it. This OPTIONAL seam restores that observability: the +// envelope yields `reporter.report(cause)` before rendering the 500. +// +// The default Layer ({@link McpErrorReporterNoop}) is a no-op, so host-mcp stays +// decoupled — a provider overrides it to forward the cause to its own capture. +// =========================================================================== + +export class McpErrorReporter extends Context.Service< + McpErrorReporter, + { + /** + * Report an orchestration defect the envelope is about to render as a + * JSON-RPC 500. Never fails (the 500 is rendered regardless); a provider + * forwards the cause to Sentry / its `ErrorCapture` here. + */ + readonly report: (cause: Cause.Cause) => Effect.Effect; + } +>()("@executor-js/host-mcp/McpErrorReporter") {} + +/** + * The no-op default. host-mcp ships this so the envelope can always resolve the + * seam; providers override it (cloud: Sentry capture + console; self-host: + * `ErrorCapture`) to regain orchestration-defect observability. + */ +export const McpErrorReporterNoop: Layer.Layer = Layer.succeed(McpErrorReporter)({ + report: () => Effect.void, +}); diff --git a/packages/hosts/mcp/src/server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts similarity index 99% rename from packages/hosts/mcp/src/server.test.ts rename to packages/hosts/mcp/src/tool-server.test.ts index 34a5b669e..d41b1419b 100644 --- a/packages/hosts/mcp/src/server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -9,7 +9,7 @@ import type * as Cause from "effect/Cause"; import { FormElicitation, ToolId, UrlElicitation } from "@executor-js/sdk"; import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; -import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./server"; +import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; // --------------------------------------------------------------------------- // Helpers diff --git a/packages/hosts/mcp/src/server.ts b/packages/hosts/mcp/src/tool-server.ts similarity index 100% rename from packages/hosts/mcp/src/server.ts rename to packages/hosts/mcp/src/tool-server.ts diff --git a/packages/kernel/runtime-dynamic-worker/scripts/test-globalsetup.ts b/packages/kernel/runtime-dynamic-worker/scripts/test-globalsetup.ts index 89f19e783..e288c4e41 100644 --- a/packages/kernel/runtime-dynamic-worker/scripts/test-globalsetup.ts +++ b/packages/kernel/runtime-dynamic-worker/scripts/test-globalsetup.ts @@ -1,5 +1,4 @@ import { collectTables } from "@executor-js/sdk"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; import { createPgliteRuntime, type PgliteRuntime } from "./pglite"; const PORT = 5435; @@ -9,7 +8,7 @@ let runtime: PgliteRuntime | undefined; export default async function setup() { runtime = await createPgliteRuntime({ - tables: collectTables([openApiPlugin()] as const), + tables: collectTables(), namespace: DATABASE_NAMESPACE, host: "127.0.0.1", port: PORT, diff --git a/packages/kernel/runtime-dynamic-worker/src/integration.test.ts b/packages/kernel/runtime-dynamic-worker/src/integration.test.ts index 7919629ee..88035c564 100644 --- a/packages/kernel/runtime-dynamic-worker/src/integration.test.ts +++ b/packages/kernel/runtime-dynamic-worker/src/integration.test.ts @@ -190,7 +190,7 @@ const buildSandboxBridge = (spec: string, namespace: string, baseUrl = "https:// openApiPlugin({ httpClientLayer: recording.layer }), memorySecretsPlugin(), ] as const; - const tables = collectTables(plugins); + const tables = collectTables(); const sql = postgres(DATABASE_URL, { max: 1, idle_timeout: 0, diff --git a/packages/plugins/encrypted-secrets/CHANGELOG.md b/packages/plugins/encrypted-secrets/CHANGELOG.md new file mode 100644 index 000000000..850a61fb1 --- /dev/null +++ b/packages/plugins/encrypted-secrets/CHANGELOG.md @@ -0,0 +1,6 @@ +# @executor-js/plugin-encrypted-secrets changelog + +This file exists for `changesets/action@v1` compatibility (it reads every +workspace package's `CHANGELOG.md` to build the Version Packages PR). +Canonical user-facing release notes are at `apps/cli/release-notes/next.md` +and on the GitHub Releases page. diff --git a/packages/plugins/encrypted-secrets/package.json b/packages/plugins/encrypted-secrets/package.json new file mode 100644 index 000000000..26898eaf1 --- /dev/null +++ b/packages/plugins/encrypted-secrets/package.json @@ -0,0 +1,26 @@ +{ + "name": "@executor-js/plugin-encrypted-secrets", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "typecheck": "tsgo --noEmit", + "test": "bunx --bun vitest run", + "test:watch": "bunx --bun vitest" + }, + "dependencies": { + "@executor-js/sdk": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "bun-types": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/plugins/encrypted-secrets/src/index.test.ts b/packages/plugins/encrypted-secrets/src/index.test.ts new file mode 100644 index 000000000..20fd2ac46 Binary files /dev/null and b/packages/plugins/encrypted-secrets/src/index.test.ts differ diff --git a/packages/plugins/encrypted-secrets/src/index.ts b/packages/plugins/encrypted-secrets/src/index.ts new file mode 100644 index 000000000..0dc112690 --- /dev/null +++ b/packages/plugins/encrypted-secrets/src/index.ts @@ -0,0 +1,149 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +import { Effect } from "effect"; + +import { + definePlugin, + StorageError, + type PluginCtx, + type SecretProvider, +} from "@executor-js/sdk/core"; + +// --------------------------------------------------------------------------- +// Encrypted DB-backed secret provider for self-host. +// +// Secret values are stored AES-256-GCM-encrypted in the executor's +// plugin-storage table (scope-partitioned, scope-policy enforced) — never in +// plaintext, unlike the file-secrets provider. The master key comes from the +// host (EXECUTOR_SECRET_KEY or a persisted key file); a random per-value IV + +// auth tag are stored alongside the ciphertext. Only node:crypto is used. +// +// This is the multi-tenant-safe default writable provider for the self-hosted +// server, replacing the OS-keychain/plaintext-file providers that assume a +// single desktop user. +// --------------------------------------------------------------------------- + +type PluginStorage = PluginCtx["pluginStorage"]; + +const COLLECTION = "secrets"; +const KEY_SALT = "executor-encrypted-secrets/v1"; +const PAYLOAD_VERSION = "v1"; + +/** Derive a 32-byte AES key from an arbitrary-length master key string. */ +const deriveKey = (master: string): Buffer => scryptSync(master, KEY_SALT, 32); + +const encryptSecret = (key: Buffer, plaintext: string): Effect.Effect => + Effect.try({ + try: () => { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return [ + PAYLOAD_VERSION, + iv.toString("base64"), + tag.toString("base64"), + ciphertext.toString("base64"), + ].join("."); + }, + catch: (cause) => new StorageError({ message: "Failed to encrypt secret", cause }), + }); + +const decryptSecret = (key: Buffer, payload: string): Effect.Effect => + Effect.try({ + // A malformed payload, a wrong key, or tampered bytes all surface here: + // GCM verification fails in `decipher.final()`, and bad base64/arity throws + // before that — both land in the StorageError channel. + try: () => { + const parts = payload.split("."); + const iv = Buffer.from(parts[1] ?? "", "base64"); + const tag = Buffer.from(parts[2] ?? "", "base64"); + const ciphertext = Buffer.from(parts[3] ?? "", "base64"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + }, + catch: (cause) => new StorageError({ message: "Failed to decrypt secret", cause }), + }); + +const makeEncryptedProvider = ( + key: Buffer, + storage: PluginStorage, + listScope: string, +): SecretProvider => ({ + key: "encrypted", + writable: true, + + get: (secretId, scope) => + storage + .getAtScope({ collection: COLLECTION, key: secretId, scope }) + .pipe( + Effect.flatMap((entry) => (entry ? decryptSecret(key, entry.data) : Effect.succeed(null))), + ), + + has: (secretId, scope) => + storage + .getAtScope({ collection: COLLECTION, key: secretId, scope }) + .pipe(Effect.map((entry) => entry !== null)), + + set: (secretId, value, scope) => + encryptSecret(key, value).pipe( + Effect.flatMap((payload) => + storage.put({ collection: COLLECTION, key: secretId, scope, data: payload }), + ), + Effect.asVoid, + ), + + delete: (secretId, scope) => + storage + .getAtScope({ collection: COLLECTION, key: secretId, scope }) + .pipe( + Effect.flatMap((entry) => + entry + ? storage.remove({ collection: COLLECTION, key: secretId, scope }).pipe(Effect.as(true)) + : Effect.succeed(false), + ), + ), + + // Scope-agnostic by interface; like file-secrets we surface the innermost + // scope for display. Per-call get/set/delete honor the explicit scope arg. + list: () => + storage + .list({ collection: COLLECTION }) + .pipe( + Effect.map((entries) => + entries + .filter((entry) => String(entry.scopeId) === listScope) + .map((entry) => ({ id: entry.key, name: entry.key })), + ), + ), +}); + +export interface EncryptedSecretsPluginConfig { + /** + * Master key (any non-empty string) — derived to 32 bytes via scrypt. The + * host is responsible for supplying a strong, persistent key + * (EXECUTOR_SECRET_KEY or a generated key file); a secret store with no key + * is unsafe, so this is required. + */ + readonly key: string; +} + +export const encryptedSecretsPlugin = definePlugin((options?: EncryptedSecretsPluginConfig) => { + const master = options?.key; + if (!master) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a secret store with no master key is unsafe; fail loud at construction + throw new Error("encryptedSecretsPlugin requires a non-empty `key`"); + } + const derivedKey = deriveKey(master); + return { + id: "encryptedSecrets" as const, + storage: () => ({}), + secretProviders: (ctx: PluginCtx) => [ + makeEncryptedProvider(derivedKey, ctx.pluginStorage, ctx.scopes[0]!.id), + ], + }; +}); + +// Exported for host-side tests / reuse. +export { deriveKey, encryptSecret, decryptSecret }; diff --git a/packages/plugins/encrypted-secrets/tsconfig.json b/packages/plugins/encrypted-secrets/tsconfig.json new file mode 100644 index 000000000..eebc1e6f1 --- /dev/null +++ b/packages/plugins/encrypted-secrets/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": { + "preferSchemaOverJson": "off" + } + } + ] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/plugins/encrypted-secrets/tsup.config.ts b/packages/plugins/encrypted-secrets/tsup.config.ts new file mode 100644 index 000000000..5769be1ec --- /dev/null +++ b/packages/plugins/encrypted-secrets/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + format: ["esm"], + dts: false, + sourcemap: true, + clean: true, + external: [/^@executor-js\//, /^effect/, /^@effect\//, "node:crypto"], +}); diff --git a/packages/plugins/encrypted-secrets/vitest.config.ts b/packages/plugins/encrypted-secrets/vitest.config.ts new file mode 100644 index 000000000..5bfa2d586 --- /dev/null +++ b/packages/plugins/encrypted-secrets/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + passWithNoTests: true, + }, +}); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index ccf8f2af8..cd87e3611 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -1209,6 +1209,16 @@ const toOpenApiSourceConfig = ( } return { kind: "openapi", + // TODO(storage): the entire resolved spec is inlined into the persisted + // source config (and thus a single plugin_storage row). Large specs (e.g. + // Vercel's ~7MB) exceed per-value limits on some backends (Cloudflare D1 + // caps a value at ~1-2MB -> SQLITE_TOOBIG). It should instead be written + // through the executor's `blobs` (BlobStore) seam, storing only a reference + // here, so large specs live in object storage (R2/S3/filesystem) rather than + // a relational row. For `kind: "url"` sources the spec is also re-fetchable, + // so we could store just the URL + a content hash and rehydrate on refresh. + // (The Cloudflare host currently works around this with an R2 offload wrapper + // in apps/host-cloudflare/src/db; this is the proper plugin-level fix.) spec: specInputToConfigString(config.spec), baseUrl: config.baseUrl, namespace, diff --git a/packages/plugins/openapi/src/sdk/real-specs.test.ts b/packages/plugins/openapi/src/sdk/real-specs.test.ts index f96ae6116..a01f287b6 100644 --- a/packages/plugins/openapi/src/sdk/real-specs.test.ts +++ b/packages/plugins/openapi/src/sdk/real-specs.test.ts @@ -12,7 +12,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { createExecutor, Scope, ScopeId } from "@executor-js/sdk"; -import type { ToolSchema } from "@executor-js/sdk/core"; +import type { ToolSchemaView } from "@executor-js/sdk/core"; import { makeTestConfig, memorySecretsPlugin } from "@executor-js/sdk/testing"; import type { ParsedDocument } from "./parse"; @@ -75,7 +75,7 @@ const testScope = Scope.make({ name: "Real spec baseline", createdAt: new Date(0), }); -const schemaCache = new Map(); +const schemaCache = new Map(); const getRegisteredToolSchema = (namespace: string, specText: string, toolId: string) => Effect.gen(function* () { @@ -128,7 +128,7 @@ const extractionSummary = (result: ExtractionResult, selectedOperationIds: reado ), }); -const schemaPreviewSummary = (schema: ToolSchema) => { +const schemaPreviewSummary = (schema: ToolSchemaView) => { const schemaDefinitions = schema.schemaDefinitions ?? {}; const typeScriptDefinitions = schema.typeScriptDefinitions ?? {}; return { diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.ts b/packages/plugins/workos-vault/src/sdk/secret-store.ts index e7fec6cfe..7b11de8eb 100644 --- a/packages/plugins/workos-vault/src/sdk/secret-store.ts +++ b/packages/plugins/workos-vault/src/sdk/secret-store.ts @@ -2,6 +2,7 @@ import { Effect, Option, Predicate, Schema } from "effect"; import { type PluginStorageEntry, + parseUserOrgScopeId, StorageError, type SecretProvider, type StorageDeps, @@ -150,12 +151,15 @@ const isKekNotReadyError = (error: WorkOSVaultClientError): boolean => export type WorkOSVaultContextForScope = (scopeId: string) => Record; export const defaultWorkOSVaultContextForScope: WorkOSVaultContextForScope = (scopeId) => { - const m = scopeId.match(/^user-org:([^:]+):([^:]+)$/); + // Parser is single-sourced in `@executor-js/sdk` alongside the producer + // (`userOrgScopeId` / `makeUserOrgScopeStack`), so the id shape the host apps + // emit and the shape we split here cannot drift. + const parsed = parseUserOrgScopeId(scopeId); const base: Record = { app: "executor", - organization_id: m ? m[2]! : scopeId, + organization_id: parsed ? parsed.organizationId : scopeId, }; - if (m) base.user_id = m[1]!; + if (parsed) base.user_id = parsed.userId; return base; }; diff --git a/packages/react/package.json b/packages/react/package.json index 4e07514ca..c208752de 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -8,6 +8,7 @@ "./api/*": "./src/api/*.tsx", "./plugins/*": "./src/plugins/*.tsx", "./pages/*": "./src/pages/*.tsx", + "./multiplayer/*": "./src/multiplayer/*.tsx", "./components/*": "./src/components/*.tsx", "./hooks/*": "./src/hooks/*.ts", "./lib/*": "./src/lib/*.ts", diff --git a/packages/react/src/api/account-atoms.tsx b/packages/react/src/api/account-atoms.tsx new file mode 100644 index 000000000..6077a87ba --- /dev/null +++ b/packages/react/src/api/account-atoms.tsx @@ -0,0 +1,46 @@ +import * as Atom from "effect/unstable/reactivity/Atom"; + +import { AccountApiClient } from "./account-client"; +import { ReactivityKey } from "./reactivity-keys"; + +// --------------------------------------------------------------------------- +// Account atoms — typed, cached, reactive queries/mutations over the shared +// `/account/*` surface. Used by the multiplayer shell, the API-keys page, and +// the org page. Provider-neutral: identical against cloud (WorkOS) and +// self-host (Better Auth). +// --------------------------------------------------------------------------- + +// ── Identity ───────────────────────────────────────────────────────────────── + +export const meAtom = AccountApiClient.query("account", "me", { + timeToLive: "5 minutes", + reactivityKeys: [ReactivityKey.auth], +}); + +// ── API keys ─────────────────────────────────────────────────────────────── + +export const apiKeysAtom = AccountApiClient.query("account", "listApiKeys", { + reactivityKeys: [ReactivityKey.apiKeys], +}); + +export const createApiKey = AccountApiClient.mutation("account", "createApiKey"); +export const revokeApiKey = AccountApiClient.mutation("account", "revokeApiKey"); + +// ── Organization members ───────────────────────────────────────────────────── + +export const orgMembersAtom = Atom.refreshOnWindowFocus( + AccountApiClient.query("account", "listMembers", { + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.orgMembers], + }), +); + +export const orgRolesAtom = AccountApiClient.query("account", "listRoles", { + timeToLive: "5 minutes", + reactivityKeys: [ReactivityKey.orgMembers], +}); + +export const inviteMember = AccountApiClient.mutation("account", "inviteMember"); +export const removeMember = AccountApiClient.mutation("account", "removeMember"); +export const updateMemberRole = AccountApiClient.mutation("account", "updateMemberRole"); +export const updateOrgName = AccountApiClient.mutation("account", "updateOrgName"); diff --git a/packages/react/src/api/account-client.tsx b/packages/react/src/api/account-client.tsx new file mode 100644 index 000000000..20b88169e --- /dev/null +++ b/packages/react/src/api/account-client.tsx @@ -0,0 +1,33 @@ +import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { AccountHttpApi } from "@executor-js/api/client"; +import * as Effect from "effect/Effect"; + +import { reportApiClientInfrastructureCause } from "./client"; +import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection"; + +// --------------------------------------------------------------------------- +// Shared account client — the provider-neutral `/account/*` surface. +// +// A separate AtomHttpApi service from `ExecutorApiClient` (which serves the +// core executor groups), mirroring the cloud split (a core client + an auth +// client). Both the cloud (WorkOS) and self-host (Better Auth) servers +// implement these paths, so this one client works for both — auth is the +// same-origin session cookie the browser sends automatically. +// --------------------------------------------------------------------------- + +const AccountApiClient = AtomHttpApi.Service<"AccountApiClient">()("AccountApiClient", { + api: AccountHttpApi, + httpClient: FetchHttpClient.layer, + transformClient: HttpClient.mapRequest((request) => { + let next = HttpClientRequest.prependUrl(request, getExecutorApiBaseUrl()); + const authorization = getExecutorServerAuthorizationHeader(); + if (authorization) { + next = HttpClientRequest.setHeader(next, "authorization", authorization); + } + return next; + }), + transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), +}); + +export { AccountApiClient }; diff --git a/packages/react/src/api/client.tsx b/packages/react/src/api/client.tsx index 44c71c898..0a81de312 100644 --- a/packages/react/src/api/client.tsx +++ b/packages/react/src/api/client.tsx @@ -16,7 +16,7 @@ const isApiClientInfrastructureCause = (cause: Cause.Cause): boolean => onSome: (error) => Schema.isSchemaError(error) || HttpClientError.isHttpClientError(error), }); -const reportApiClientInfrastructureCause = (cause: Cause.Cause) => +export const reportApiClientInfrastructureCause = (cause: Cause.Cause) => Effect.sync(() => { if (!isApiClientInfrastructureCause(cause)) return; reportHandledFrontendError(cause, { diff --git a/packages/react/src/components/sonner.tsx b/packages/react/src/components/sonner.tsx index 2e324e65b..ed466c377 100644 --- a/packages/react/src/components/sonner.tsx +++ b/packages/react/src/components/sonner.tsx @@ -7,7 +7,7 @@ import { OctagonXIcon, TriangleAlertIcon, } from "lucide-react"; -import { Toaster as Sonner, type ToasterProps } from "sonner"; +import { Toaster as Sonner, toast, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { return ( @@ -34,4 +34,4 @@ const Toaster = ({ ...props }: ToasterProps) => { ); }; -export { Toaster }; +export { Toaster, toast }; diff --git a/packages/react/src/multiplayer/auth-context.tsx b/packages/react/src/multiplayer/auth-context.tsx new file mode 100644 index 000000000..34a083308 --- /dev/null +++ b/packages/react/src/multiplayer/auth-context.tsx @@ -0,0 +1,99 @@ +import React, { createContext, useContext, useEffect } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; + +import { meAtom } from "../api/account-atoms"; + +// --------------------------------------------------------------------------- +// Shared auth seam for the multiplayer apps (cloud + self-host). +// +// `useAuth()` reflects the `/account/me` query: loading → unauthenticated → +// authenticated. Provider-neutral — the only difference between cloud (WorkOS) +// and self-host (Better Auth) is which server answers `me` and how the session +// cookie was minted. Analytics stay OUT of here; a host that wants to identify +// the user (cloud → PostHog) passes an `onIdentify` callback. +// --------------------------------------------------------------------------- + +export type AuthUser = { + id: string; + email: string; + name: string | null; + avatarUrl: string | null; +}; + +export type AuthOrganization = { + id: string; + name: string; +}; + +export type AuthState = + | { status: "loading" } + | { status: "unauthenticated" } + | { status: "authenticated"; user: AuthUser; organization: AuthOrganization | null }; + +export type IdentifyFn = ( + state: Extract | { status: "unauthenticated" }, +) => void; + +const AuthContext = createContext({ status: "loading" }); + +export const useAuth = () => useContext(AuthContext); + +const AuthProviderClient = ({ + children, + onIdentify, +}: { + children: React.ReactNode; + onIdentify?: IdentifyFn; +}) => { + const result = useAtomValue(meAtom); + + const state: AuthState = AsyncResult.match(result, { + onInitial: () => ({ status: "loading" as const }), + onSuccess: ({ value }) => ({ + status: "authenticated" as const, + user: value.user, + organization: value.organization, + }), + onFailure: () => ({ status: "unauthenticated" as const }), + }); + + // Primitive identity fields so the identify effect fires only on real + // transitions (the `state` object is rebuilt every render). + const status = state.status; + const userId = state.status === "authenticated" ? state.user.id : null; + const email = state.status === "authenticated" ? state.user.email : null; + const name = state.status === "authenticated" ? state.user.name : null; + const avatarUrl = state.status === "authenticated" ? state.user.avatarUrl : null; + const organizationId = state.status === "authenticated" ? (state.organization?.id ?? null) : null; + const organizationName = + state.status === "authenticated" ? (state.organization?.name ?? null) : null; + + useEffect(() => { + if (!onIdentify) return; + if (status === "authenticated" && userId && email !== null) { + onIdentify({ + status: "authenticated", + user: { id: userId, email, name, avatarUrl }, + organization: organizationId ? { id: organizationId, name: organizationName ?? "" } : null, + }); + } else if (status === "unauthenticated") { + onIdentify({ status: "unauthenticated" }); + } + }, [onIdentify, status, userId, email, name, avatarUrl, organizationId, organizationName]); + + return {children}; +}; + +export const AuthProvider = ({ + children, + onIdentify, +}: { + children: React.ReactNode; + onIdentify?: IdentifyFn; +}) => { + if (typeof window === "undefined") { + return {children}; + } + return {children}; +}; diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx new file mode 100644 index 000000000..71304e8f2 --- /dev/null +++ b/packages/react/src/multiplayer/shell.tsx @@ -0,0 +1,391 @@ +import { Link, Outlet, useLocation } from "@tanstack/react-router"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { sourcesOptimisticAtom } from "../api/atoms"; +import { useScope } from "../api/scope-context"; +import { Button } from "../components/button"; +import { Skeleton } from "../components/skeleton"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../components/dropdown-menu"; +import { SourceFavicon, sourcePresetIconUrl } from "../components/source-favicon"; +import { CommandPalette } from "../components/command-palette"; +import { useSourcePlugins } from "@executor-js/sdk/client"; +import { useAuth } from "./auth-context"; + +// --------------------------------------------------------------------------- +// Shared multiplayer shell (cloud + self-host). +// +// Provider-neutral: identity comes from the shared `useAuth()` seam. The bits +// that genuinely differ per product are injected: +// - `onSignOut` how the session is ended (WorkOS logout vs Better Auth) +// - `orgMenuSlot` org switcher / create-org (cloud only) +// - `supportSlot` support dialog button (cloud only) +// - `navItems` which sections show (e.g. cloud adds Billing) +// Everything visual is identical so both products look the same. +// --------------------------------------------------------------------------- + +export type ShellNavItem = { readonly to: string; readonly label: string }; + +/** Sources lives at "/", plus the standard tool-management sections. Hosts + * spread this and append their own (e.g. Organization, Billing). */ +export const defaultShellNavItems: ReadonlyArray = [ + { to: "/", label: "Sources" }, + { to: "/connections", label: "Connections" }, + { to: "/secrets", label: "Secrets" }, + { to: "/policies", label: "Policies" }, +]; + +export interface ShellProps { + /** End the session. Cloud POSTs its logout path; self-host calls Better Auth. */ + readonly onSignOut: () => void | Promise; + /** Nav sections; defaults to {@link defaultShellNavItems}. */ + readonly navItems?: ReadonlyArray; + /** Where the "API keys" footer link goes; null hides it. Default "/api-keys". */ + readonly apiKeysTo?: string | null; + /** Injected into the account dropdown — cloud's org switcher / create-org. */ + readonly orgMenuSlot?: ReactNode; + /** Injected support button above the account footer (cloud). */ + readonly supportSlot?: ReactNode; +} + +// ── Brand ──────────────────────────────────────────────────────────────── + +function Brand(props: { onNavigate?: () => void }) { + return ( + + executor + + Beta + + + ); +} + +// ── NavItem ────────────────────────────────────────────────────────────── + +function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { + return ( + + {props.label} + + ); +} + +// ── SourceList ─────────────────────────────────────────────────────────── + +function SourceList(props: { pathname: string; onNavigate?: () => void }) { + const scopeId = useScope(); + const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); + const sourcePlugins = useSourcePlugins(); + + return AsyncResult.match(sources, { + onInitial: () => ( +
+ {[80, 65, 72, 58, 68].map((w, i) => ( +
+ + +
+ ))} +
+ ), + onFailure: () => ( +
No sources yet
+ ), + onSuccess: ({ value }) => + value.length === 0 ? ( +
+ No sources yet +
+ ) : ( +
+ {value.map((s) => { + const detailPath = `/sources/${s.id}`; + const active = + props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); + return ( + + + {s.name} + + {s.kind} + + + ); + })} +
+ ), + }); +} + +// ── Avatar / initials ────────────────────────────────────────────────────── + +function initialsFor(name: string | null, email: string) { + if (name) { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + } + return email[0]!.toUpperCase(); +} + +function Avatar(props: { url: string | null; name: string | null; email: string }) { + if (props.url) { + return ; + } + return ( +
+ {initialsFor(props.name, props.email)} +
+ ); +} + +// ── UserFooter ────────────────────────────────────────────────────────── + +function UserFooter(props: Pick) { + const auth = useAuth(); + if (auth.status !== "authenticated") return null; + const apiKeysTo = props.apiKeysTo === undefined ? "/api-keys" : props.apiKeysTo; + + return ( +
+ + + + + + {props.orgMenuSlot} + {apiKeysTo && ( + <> + + API keys + + + + )} + + Signed in as + + + +
+

+ {auth.user.name ?? auth.user.email} +

+ {auth.user.name && ( +

{auth.user.email}

+ )} +
+
+ void props.onSignOut()} + > + Sign out + +
+
+
+ ); +} + +// ── SidebarContent ─────────────────────────────────────────────────────── + +function SidebarContent( + props: ShellProps & { pathname: string; onNavigate?: () => void; showBrand?: boolean }, +) { + const navItems = props.navItems ?? defaultShellNavItems; + return ( + <> + {props.showBrand !== false && ( +
+ +
+ )} + + + + {props.supportSlot &&
{props.supportSlot}
} + + + + ); +} + +// ── Shell ───────────────────────────────────────────────────────────────── + +export function Shell(props: ShellProps) { + const location = useLocation(); + const pathname = location.pathname; + const lastPathname = useRef(pathname); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + if (lastPathname.current !== pathname) { + lastPathname.current = pathname; + if (mobileSidebarOpen) setMobileSidebarOpen(false); + } + + useEffect(() => { + if (!mobileSidebarOpen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [mobileSidebarOpen]); + + return ( +
+ + {/* Desktop sidebar */} + + + {/* Mobile sidebar overlay */} + {mobileSidebarOpen && ( +
+ {/* oxlint-disable-next-line react/forbid-elements */} + +
+ setMobileSidebarOpen(false)} + showBrand={false} + /> +
+ + )} + + {/* Main content */} +
+ {/* Mobile top bar */} +
+ + +
+
+ + +
+ + ); +} diff --git a/packages/react/src/pages/api-keys.tsx b/packages/react/src/pages/api-keys.tsx new file mode 100644 index 000000000..15e7e8768 --- /dev/null +++ b/packages/react/src/pages/api-keys.tsx @@ -0,0 +1,266 @@ +import { useState } from "react"; +import { Exit } from "effect"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { toast } from "sonner"; +import { apiKeyWriteKeys } from "../api/reactivity-keys"; +import { apiKeysAtom, createApiKey, revokeApiKey } from "../api/account-atoms"; +import { Button } from "../components/button"; +import { CopyButton } from "../components/copy-button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/dialog"; +import { Input } from "../components/input"; +import { Label } from "../components/label"; + +// --------------------------------------------------------------------------- +// Shared API-keys page. Reads/writes the provider-neutral `/account/api-keys` +// surface, so it works identically on cloud (WorkOS) and self-host (Better +// Auth). API keys are how a user authenticates the Executor API + MCP endpoint +// from scripts/agents (Authorization: Bearer ). +// --------------------------------------------------------------------------- + +type ApiKeySummary = { + readonly id: string; + readonly name: string; + readonly obfuscatedValue: string; + readonly createdAt: string; + readonly lastUsedAt: string | null; +}; + +type CreatedKey = ApiKeySummary & { readonly value: string }; + +const formatDate = (value: string | null): string => { + if (!value) return "Never"; + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +}; + +const defaultApiKeyName = (): string => + `API key ${new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date())}`; + +export function ApiKeysPage() { + const result = useAtomValue(apiKeysAtom); + const doCreate = useAtomSet(createApiKey, { mode: "promiseExit" }); + const doRevoke = useAtomSet(revokeApiKey, { mode: "promiseExit" }); + const [createOpen, setCreateOpen] = useState(false); + const [name, setName] = useState(""); + const [createdKey, setCreatedKey] = useState(null); + const [creating, setCreating] = useState(false); + const [revokingId, setRevokingId] = useState(null); + + const handleCreate = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setCreating(true); + const exit = await doCreate({ payload: { name: trimmed }, reactivityKeys: apiKeyWriteKeys }); + setCreating(false); + if (Exit.isSuccess(exit)) { + setCreatedKey(exit.value); + setName(""); + toast.success("API key created"); + return; + } + toast.error("Failed to create API key"); + }; + + const handleRevoke = async (key: ApiKeySummary) => { + setRevokingId(key.id); + const exit = await doRevoke({ params: { apiKeyId: key.id }, reactivityKeys: apiKeyWriteKeys }); + setRevokingId(null); + if (Exit.isSuccess(exit)) { + toast.success(`Revoked ${key.name}`); + return; + } + toast.error("Failed to revoke API key"); + }; + + const closeCreate = (open: boolean) => { + setCreateOpen(open); + if (!open) { + setName(""); + setCreatedKey(null); + setCreating(false); + } + }; + + return ( +
+
+
+
+

API keys

+

+ User keys for accessing the Executor API and MCP endpoint from scripts and tools. +

+
+ + Authorization: Bearer <api-key> + + +
+

+ API keys work as PATs and have full access to your account. +

+
+ +
+ + {AsyncResult.match(result, { + onInitial: () => ( +
+ Loading API keys... +
+ ), + onFailure: () => ( +
+ Failed to load API keys +
+ ), + onSuccess: ({ value }) => + value.apiKeys.length === 0 ? ( +
+

No API keys

+

+ Create a key and send it in the Authorization Bearer header. +

+
+ ) : ( +
+
+ Name + Created + Last used + Actions +
+ {value.apiKeys.map((key: ApiKeySummary) => ( +
+
+

{key.name}

+

+ {key.obfuscatedValue} +

+
+

+ {formatDate(key.createdAt)} +

+

+ {formatDate(key.lastUsedAt)} +

+ +
+ ))} +
+ ), + })} +
+ + + + + Create API key + + The key will act as your user in the current organization. + + + + {createdKey ? ( +
+
+ +
+ + +
+
+
+ +
+ + +
+
+

+ Send this value as a Bearer token. It is only shown once. +

+
+ ) : ( +
+
+ + setName(event.target.value)} + placeholder="Local CLI" + maxLength={80} + autoFocus + /> +
+
+ )} + + + + + + {!createdKey && ( + + )} + +
+
+
+ ); +} diff --git a/packages/react/src/pages/org.tsx b/packages/react/src/pages/org.tsx new file mode 100644 index 000000000..21f9cada2 --- /dev/null +++ b/packages/react/src/pages/org.tsx @@ -0,0 +1,480 @@ +import { useReducer, useState } from "react"; +import { Exit, Match } from "effect"; +import { useAtomValue, useAtomSet } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { toast } from "sonner"; +import { orgMemberWriteKeys, orgInfoWriteKeys } from "../api/reactivity-keys"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from "../components/dialog"; +import { Button } from "../components/button"; +import { Badge } from "../components/badge"; +import { Input } from "../components/input"; +import { Label } from "../components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "../components/dropdown-menu"; +import { + orgMembersAtom, + orgRolesAtom, + inviteMember, + removeMember, + updateMemberRole, + updateOrgName, +} from "../api/account-atoms"; +import { useAuth } from "../multiplayer/auth-context"; + +// --------------------------------------------------------------------------- +// Shared organization page — members + roles + invites + org name, over the +// provider-neutral `/account/*` surface. Cloud-only surfaces (domain +// verification, seat/billing gating) are NOT here; cloud composes those +// alongside this page as its own additions. +// --------------------------------------------------------------------------- + +type MemberData = { + id: string; + email: string; + name: string | null; + avatarUrl: string | null; + role: string; + status: string; + lastActiveAt: string | null; + isCurrentUser: boolean; +}; + +type RoleData = { slug: string; name: string }; + +type InviteState = { + email: string; + roleSlug: string; + status: "idle" | "sending" | "error"; +}; + +const initialInviteState: InviteState = { email: "", roleSlug: "member", status: "idle" }; + +type InviteAction = + | { type: "setEmail"; email: string } + | { type: "setRole"; roleSlug: string } + | { type: "send" } + | { type: "error" } + | { type: "reset" }; + +function inviteReducer(state: InviteState, action: InviteAction): InviteState { + return Match.value(action).pipe( + Match.discriminator("type")("setEmail", (a) => ({ ...state, email: a.email })), + Match.discriminator("type")("setRole", (a) => ({ ...state, roleSlug: a.roleSlug })), + Match.discriminator("type")("send", () => ({ ...state, status: "sending" as const })), + Match.discriminator("type")("error", () => ({ ...state, status: "error" as const })), + Match.discriminator("type")("reset", () => initialInviteState), + Match.exhaustive, + ); +} + +function formatLastActive(lastActiveAt: string | null): string { + if (!lastActiveAt) return "—"; + const date = new Date(lastActiveAt); + const diffMins = Math.floor((Date.now() - date.getTime()) / 60000); + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays}d ago`; + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export function OrgPage() { + const auth = useAuth(); + const organizationName = + auth.status === "authenticated" ? (auth.organization?.name ?? "Organization") : "Organization"; + const membersResult = useAtomValue(orgMembersAtom); + const rolesResult = useAtomValue(orgRolesAtom); + const doRemove = useAtomSet(removeMember, { mode: "promiseExit" }); + const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" }); + const doUpdateOrgName = useAtomSet(updateOrgName, { mode: "promiseExit" }); + const [inviteOpen, setInviteOpen] = useState(false); + const [editName, setEditName] = useState(organizationName); + const [savingName, setSavingName] = useState(false); + const [search, setSearch] = useState(""); + + const roles = AsyncResult.match(rolesResult, { + onInitial: () => [] as readonly RoleData[], + onFailure: () => [] as readonly RoleData[], + onSuccess: ({ value }) => value.roles, + }); + + const handleRemove = async (membershipId: string, name: string) => { + const exit = await doRemove({ params: { membershipId }, reactivityKeys: orgMemberWriteKeys }); + toast[Exit.isSuccess(exit) ? "success" : "error"]( + Exit.isSuccess(exit) ? `Removed ${name}` : "Failed to remove member", + ); + }; + + const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => { + const exit = await doUpdateRole({ + params: { membershipId }, + payload: { roleSlug }, + reactivityKeys: orgMemberWriteKeys, + }); + toast[Exit.isSuccess(exit) ? "success" : "error"]( + Exit.isSuccess(exit) ? `Role changed to ${roleName}` : "Failed to change role", + ); + }; + + const handleSaveName = async () => { + const trimmed = editName.trim(); + if (!trimmed || trimmed === organizationName) { + setEditName(organizationName); + return; + } + setSavingName(true); + const exit = await doUpdateOrgName({ + payload: { name: trimmed }, + reactivityKeys: orgInfoWriteKeys, + }); + if (Exit.isSuccess(exit)) { + toast.success("Organization name updated"); + } else { + toast.error("Failed to update organization name"); + setEditName(organizationName); + } + setSavingName(false); + }; + + return ( +
+
+
+

Organization

+
+ +
+
+
+ + setEditName((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveName(); + }} + className="mt-1.5 h-9 text-sm" + /> +
+ {editName.trim() !== organizationName && editName.trim() !== "" && ( + + )} +
+
+ +
+
+
+

Members

+

+ People with access to this Executor instance. +

+
+ +
+ setSearch((e.target as HTMLInputElement).value)} + className="mb-3 h-9 text-sm" + /> + + {AsyncResult.match(membersResult, { + onInitial: () => ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ), + onFailure: () => ( +
+

Failed to load members

+
+ ), + onSuccess: ({ value }) => { + const members = value.members; + const filtered = search + ? members.filter( + (m: MemberData) => + m.email.toLowerCase().includes(search.toLowerCase()) || + (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), + ) + : members; + + if (filtered.length === 0) { + return ( +

+ {search ? "No matching members" : "No members yet"} +

+ ); + } + + return ( +
+ {filtered.map((member: MemberData) => ( +
+ {member.avatarUrl ? ( + + ) : ( +
+ {member.name + ? member.name + .split(" ") + .map((n: string) => n[0]) + .join("") + .slice(0, 2) + .toUpperCase() + : member.email[0]!.toUpperCase()} +
+ )} + +
+
+

+ {member.name ?? member.email} +

+ {member.isCurrentUser && ( + You + )} + {member.status === "pending" && ( + + Invited + + )} +
+ {member.name && ( +

+ {member.email} +

+ )} +
+ +

+ {member.role} +

+ +

+ {formatLastActive(member.lastActiveAt)} +

+ + {!member.isCurrentUser ? ( + + + + + + {roles.length > 0 && ( + <> + + + Change role + + + {roles.map((role: RoleData) => ( + + handleChangeRole(member.id, role.slug, role.name) + } + > + {role.name} + + ))} + + + + + )} + handleRemove(member.id, member.name ?? member.email)} + > + Remove member + + + + ) : ( +
+ )} +
+ ))} +
+ ); + }, + })} +
+ + +
+
+ ); +} + +function InviteDialog(props: { + open: boolean; + onOpenChange: (v: boolean) => void; + roles: readonly RoleData[]; +}) { + const [state, dispatch] = useReducer(inviteReducer, initialInviteState); + const doInvite = useAtomSet(inviteMember, { mode: "promiseExit" }); + + const handleInvite = async () => { + if (!state.email.trim()) return; + dispatch({ type: "send" }); + const exit = await doInvite({ + payload: { + email: state.email.trim(), + ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}), + }, + reactivityKeys: orgMemberWriteKeys, + }); + if (Exit.isSuccess(exit)) { + toast.success(`Invitation sent to ${state.email.trim()}`); + dispatch({ type: "reset" }); + props.onOpenChange(false); + return; + } + dispatch({ type: "error" }); + }; + + return ( + { + if (!v) dispatch({ type: "reset" }); + props.onOpenChange(v); + }} + > + + + Invite member + + Send an email invitation to join your organization. + + + +
+
+ + + dispatch({ type: "setEmail", email: (e.target as HTMLInputElement).value }) + } + onKeyDown={(e) => { + if (e.key === "Enter") handleInvite(); + }} + className="text-sm h-9" + /> +
+ + {props.roles.length > 0 && ( +
+ + +
+ )} + + {state.status === "error" && ( +
+

+ Failed to send invitation. Please try again. +

+
+ )} +
+ + + + + + + +
+
+ ); +} diff --git a/patches/libsql@0.5.29.patch b/patches/libsql@0.5.29.patch new file mode 100644 index 000000000..3e78bf4ed --- /dev/null +++ b/patches/libsql@0.5.29.patch @@ -0,0 +1,19 @@ +diff --git a/index.js b/index.js +index e24987954ec427320f51fd8037f9754b60ffa363..30522619ea99c6f988315b192525b647888a08e9 100644 +--- a/index.js ++++ b/index.js +@@ -4,6 +4,14 @@ const { load, currentTarget } = require("@neon-rs/load"); + const { familySync, GLIBC, MUSL } = require("detect-libc"); + + function requireNative() { ++ // Executor patch: inside a `bun build --compile` binary the platform package ++ // `@libsql/` isn't in bunfs and bun resolves this require natively ++ // (no JS module-resolver hook), so the normal walk fails. The Executor CLI ++ // copies the right `.node` next to the executable and points this env var at ++ // it; load it directly before the in-bunfs walk. ++ if (process.env.EXECUTOR_LIBSQL_NATIVE_PATH) { ++ return require(process.env.EXECUTOR_LIBSQL_NATIVE_PATH); ++ } + if (process.env.LIBSQL_JS_DEV) { + return load(__dirname) + } diff --git a/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js b/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js index 06731100d..75ca1db1e 100644 --- a/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js +++ b/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js @@ -3,20 +3,15 @@ import { getPropertyName, isIdentifier, toRepoRelative, unwrapExpression } from const message = "Do not access cloud executor tables directly outside DB schema wiring. Executor-domain table access must go through the scoped SDK adapter so scope_id filtering cannot be skipped."; -const allowedFiles = new Set([ - "apps/cloud/src/services/db.ts", - "apps/cloud/src/services/db.schema.test.ts", -]); +const allowedFiles = new Set(["apps/cloud/src/db/db.ts", "apps/cloud/src/db/db.schema.test.ts"]); const isCloudSource = (filename) => toRepoRelative(filename).startsWith("apps/cloud/src/"); const isDirectExecutorSchemaImport = (specifier) => specifier === "./executor-schema" || specifier === "./executor-schema.ts" || - specifier === "../services/executor-schema" || - specifier === "../services/executor-schema.ts" || - specifier.endsWith("/services/executor-schema") || - specifier.endsWith("/services/executor-schema.ts"); + specifier.endsWith("/db/executor-schema") || + specifier.endsWith("/db/executor-schema.ts"); const coreTableNames = new Set([ "source",