Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions apps/cloud/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,52 @@ import { defineExecutorConfig } from "@executor-js/sdk";
import { openApiPlugin } from "@executor-js/plugin-openapi";
import { mcpPlugin } from "@executor-js/plugin-mcp";
import { graphqlPlugin } from "@executor-js/plugin-graphql";
import { workosVaultPlugin } from "@executor-js/plugin-workos-vault";
import {
workosVaultPlugin,
type WorkOSVaultClient,
} from "@executor-js/plugin-workos-vault";

// ---------------------------------------------------------------------------
// Executor config for CLI schema generation.
// Single source of truth for the cloud app's plugin list.
//
// The CLI reads `plugins` + `dialect` to produce a drizzle schema file.
// Plugin credentials are stubs — the CLI only reads `plugin.schema`,
// never calls the plugin at runtime.
// Consumed by:
// - the schema-gen CLI (reads `plugin.schema` only; calls `plugins({})`)
// - the host runtime (calls `plugins({ workosCredentials })` per request)
// - the test harness (calls `plugins({ workosVaultClient })` per test)
//
// `TDeps` is inferred directly from the factory parameter annotation —
// no global `declare module "@executor-js/sdk"` augmentation. Each
// caller (runtime / CLI / tests) passes whatever subset of the deps it
// has; all fields are optional so the CLI's `plugins({})` keeps working.
//
// Cloud only ships plugins safe to run in a multi-tenant setting — no
// stdio MCP, no keychain/file-secrets/1password/google-discovery.
// ---------------------------------------------------------------------------

interface CloudPluginDeps {
/** WorkOS vault credentials. Provided per-request from `env.WORKOS_*`
* in production; the test harness leaves this undefined and uses
* `workosVaultClient` to inject an in-memory fake instead. */
readonly workosCredentials?: {
readonly apiKey: string;
readonly clientId: string;
};
/** Pluggable WorkOS Vault HTTP client — set by the test harness to
* bypass the real WorkOS API. Production leaves this undefined and
* falls back to the credential-driven default. */
readonly workosVaultClient?: WorkOSVaultClient;
}

export default defineExecutorConfig({
dialect: "pg",
plugins: [
openApiPlugin(),
mcpPlugin({ dangerouslyAllowStdioMCP: false }),
graphqlPlugin(),
workosVaultPlugin({
credentials: { apiKey: "", clientId: "" },
}),
],
plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) =>
[
openApiPlugin(),
mcpPlugin({ dangerouslyAllowStdioMCP: false }),
graphqlPlugin(),
workosVaultPlugin({
credentials: workosCredentials ?? { apiKey: "", clientId: "" },
...(workosVaultClient ? { client: workosVaultClient } : {}),
}),
] as const,
});
3 changes: 2 additions & 1 deletion apps/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"db:studio": "drizzle-kit studio",
"db:studio:prod": "op run --env-file=.env.production -- drizzle-kit studio",
"db:migrate:prod": "op run --env-file=.env.production -- drizzle-kit migrate",
"build": "vite build",
"build": "turbo run build --filter @executor-js/vite-plugin && vite build",
"preview": "vite preview",
"deploy": "op run --env-file=.env.production -- bun run build && op run --env-file=.env.production -- wrangler deploy",
"cf-typegen": "wrangler types",
Expand All @@ -40,6 +40,7 @@
"@executor-js/sdk": "workspace:*",
"@executor-js/storage-core": "workspace:*",
"@executor-js/storage-postgres": "workspace:*",
"@executor-js/vite-plugin": "workspace:*",
"@microlabs/otel-cf-workers": "^1.0.0-rc.52",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "~1.9.0",
Expand Down
4 changes: 2 additions & 2 deletions apps/cloud/src/api.request-scope.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ describe("HttpRouter.toWebHandler request scoping", () => {
handler(new Request("http://test.local/")),
]);

const aBody = await a.json();
const bBody = await b.json();
const aBody = (await a.json()) as { id: number };
const bBody = (await b.json()) as { id: number };
// Two concurrent requests must see two distinct acquired counters.
// Otherwise both fibers share one postgres socket -> Cloudflare
// Workers I/O isolation crash in prod.
Expand Down
17 changes: 17 additions & 0 deletions apps/cloud/src/api/cloud-plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Single shared instantiation of the cloud plugin list.
//
// `executor.config.ts`'s `plugins()` factory is safe to call at
// 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`)
// and the per-request middleware (`protected.ts` + the test harness)
// derive their typed views — `composePluginApi(cloudPlugins)`,
// `composePluginHandlerLayer(cloudPlugins)`,
// `providePluginExtensions(cloudPlugins)`, `PluginExtensionServices<typeof
// cloudPlugins>` — from this one tuple, so adding/removing a plugin is
// still a single `executor.config.ts` edit.
import executorConfig from "../../executor.config";

export const cloudPlugins = executorConfig.plugins();
export type CloudPlugins = typeof cloudPlugins;
35 changes: 19 additions & 16 deletions apps/cloud/src/api/protected-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
import { HttpRouter, HttpServer } from "effect/unstable/http";
import { Layer } from "effect";

import { observabilityMiddleware } from "@executor-js/api";
import {
CoreExecutorApi,
observabilityMiddleware,
} from "@executor-js/api";
import { CoreHandlers } from "@executor-js/api/server";
import { OpenApiGroup, OpenApiHandlers } from "@executor-js/plugin-openapi/api";
import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api";
import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api";
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";
Expand All @@ -30,9 +29,13 @@ import { ErrorCaptureLive } from "../observability";
// 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.
export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
.add(McpGroup)
.add(GraphqlGroup);
//
// `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<typeof ProtectedCloudApi>` directly, with no
// per-plugin Group imports at the host.
export const ProtectedCloudApi = composePluginApi(cloudPlugins);

const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);

Expand All @@ -49,14 +52,14 @@ export const SharedServices = Layer.mergeAll(

export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 });

// Every handler the ProtectedCloudApi routes to. The test harness builds
// its own api-live by merging this with its own per-request middleware
// fakes; prod uses `ProtectedCloudApiLive` below.
// 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,
OpenApiHandlers,
McpHandlers,
GraphqlHandlers,
composePluginHandlerLayer(cloudPlugins),
);

// `ErrorCaptureLive` is provided above the handler + middleware layers
Expand Down
39 changes: 13 additions & 26 deletions apps/cloud/src/api/protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import { Effect, Layer } from "effect";
import {
ExecutionEngineService,
ExecutorService,
providePluginExtensions,
type PluginExtensionServices,
} from "@executor-js/api/server";
import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api";
import { McpExtensionService } from "@executor-js/plugin-mcp/api";
import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api";

import { cloudPlugins, type CloudPlugins } from "./cloud-plugins";
import { AuthContext } from "../auth/middleware";
import { authorizeOrganization } from "../auth/authorize-organization";
import { UserStoreService } from "../auth/context";
Expand All @@ -33,23 +33,10 @@ import {
} from "./protected-layers";
import { requestScopedMiddleware } from "./request-scoped";

// 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.
//
// Replaces both the old outer `Effect.gen` in this file (which did its own
// WorkOS lookup) and the per-route `OrgAuth` HttpApiMiddleware (which did
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

booo

// 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.
// 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);

// One `HttpRouter` middleware that:
// 1. authenticates the WorkOS sealed session,
Expand All @@ -74,13 +61,15 @@ import { requestScopedMiddleware } from "./request-scoped";
// 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
| OpenApiExtensionService
| McpExtensionService
| GraphqlExtensionService;
| PluginExtensionServices<CloudPlugins>;
}>()(
Effect.gen(function* () {
const longLived = yield* Effect.context<WorkOSAuth | AutumnService>();
Expand Down Expand Up @@ -117,9 +106,7 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{
Effect.provideService(AuthContext, auth),
Effect.provideService(ExecutorService, executor),
Effect.provideService(ExecutionEngineService, engine),
Effect.provideService(OpenApiExtensionService, executor.openapi),
Effect.provideService(McpExtensionService, executor.mcp),
Effect.provideService(GraphqlExtensionService, executor.graphql),
provideExecutorExtensions(executor),
);
}).pipe(Effect.provideContext(longLived));
}),
Expand Down
15 changes: 4 additions & 11 deletions apps/cloud/src/mcp-session.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,7 @@ import {
makePostgresAdapter,
makePostgresBlobStore,
} from "@executor-js/storage-postgres";
import { openApiPlugin } from "@executor-js/plugin-openapi";
import { mcpPlugin } from "@executor-js/plugin-mcp";
import { graphqlPlugin } from "@executor-js/plugin-graphql";
import { workosVaultPlugin } from "@executor-js/plugin-workos-vault";

import executorConfig from "../executor.config";
import { DbService } from "./services/db";
import { makeFakeVaultClient } from "./services/__test-harness__/api-harness";

Expand Down Expand Up @@ -105,12 +101,9 @@ const buildScopedExecutor = (
) =>
Effect.gen(function* () {
const { db } = yield* DbService;
const basePlugins = [
openApiPlugin(),
mcpPlugin({ dangerouslyAllowStdioMCP: false }),
graphqlPlugin(),
workosVaultPlugin({ client: makeFakeVaultClient() }),
] as const;
const basePlugins = executorConfig.plugins({
workosVaultClient: makeFakeVaultClient(),
});
const plugins = options.withElicitingPlugin
? ([...basePlugins, elicitingTestPlugin()] as const)
: basePlugins;
Expand Down
8 changes: 6 additions & 2 deletions apps/cloud/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { PostHogProvider } from "posthog-js/react";
import { ExecutorProvider } from "@executor-js/react/api/provider";
import { Skeleton } from "@executor-js/react/components/skeleton";
import { Toaster } from "@executor-js/react/components/sonner";
import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
import { plugins as clientPlugins } from "virtual:executor/plugins-client";
import { AuthProvider, useAuth } from "../web/auth";
import { LoginPage } from "../web/pages/login";
import { OnboardingPage } from "../web/pages/onboarding";
Expand Down Expand Up @@ -161,8 +163,10 @@ function AuthGate() {
return (
<AutumnProvider pathPrefix="/api/autumn">
<ExecutorProvider fallback={<ShellSkeleton />}>
<Shell />
<Toaster />
<ExecutorPluginsProvider plugins={clientPlugins}>
<Shell />
<Toaster />
</ExecutorPluginsProvider>
</ExecutorProvider>
</AutumnProvider>
);
Expand Down
7 changes: 1 addition & 6 deletions apps/cloud/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SourcesPage } from "@executor-js/react/pages/sources";
import { openApiSourcePlugin } from "@executor-js/plugin-openapi/react";
import { mcpSourcePlugin } from "@executor-js/plugin-mcp/react";
import { graphqlSourcePlugin } from "@executor-js/plugin-graphql/react";

const sourcePlugins = [openApiSourcePlugin, mcpSourcePlugin, graphqlSourcePlugin];

export const Route = createFileRoute("/")({
component: () => <SourcesPage sourcePlugins={sourcePlugins} />,
component: SourcesPage,
});
1 change: 0 additions & 1 deletion apps/cloud/src/routes/secrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { SecretsPage } from "@executor-js/react/pages/secrets";
export const Route = createFileRoute("/secrets")({
component: () => (
<SecretsPage
secretProviderPlugins={[]}
addSecretDescription="Store a credential or API key for this organization."
showProviderInfo={false}
storageOptions={[{ value: "workos-vault", label: "WorkOS Vault" }]}
Expand Down
7 changes: 1 addition & 6 deletions apps/cloud/src/routes/sources.$namespace.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { SourceDetailPage } from "@executor-js/react/pages/source-detail";
import { openApiSourcePlugin } from "@executor-js/plugin-openapi/react";
import { mcpSourcePlugin } from "@executor-js/plugin-mcp/react";
import { graphqlSourcePlugin } from "@executor-js/plugin-graphql/react";

const sourcePlugins = [openApiSourcePlugin, mcpSourcePlugin, graphqlSourcePlugin];

export const Route = createFileRoute("/sources/$namespace")({
component: () => {
const { namespace } = Route.useParams();
return <SourceDetailPage namespace={namespace} sourcePlugins={sourcePlugins} />;
return <SourceDetailPage namespace={namespace} />;
},
});
6 changes: 0 additions & 6 deletions apps/cloud/src/routes/sources.add.$pluginKey.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { Schema } from "effect";
import { createFileRoute } from "@tanstack/react-router";
import { SourcesAddPage } from "@executor-js/react/pages/sources-add";
import { openApiSourcePlugin } from "@executor-js/plugin-openapi/react";
import { mcpSourcePlugin } from "@executor-js/plugin-mcp/react";
import { graphqlSourcePlugin } from "@executor-js/plugin-graphql/react";

const sourcePlugins = [openApiSourcePlugin, mcpSourcePlugin, graphqlSourcePlugin];

const SearchParams = Schema.toStandardSchemaV1(
Schema.Struct({
Expand All @@ -26,7 +21,6 @@ export const Route = createFileRoute("/sources/add/$pluginKey")({
url={url}
preset={preset}
namespace={namespace}
sourcePlugins={sourcePlugins}
/>
);
},
Expand Down
Loading
Loading