History

Merge branch 'main' of https://github.com/RhysSullivan/executor

Rhys Sullivan committed 7 months ago

Commit b12ecd92 parents ea110d9 30733bc27 files · +1203 −646Browse files

Unified · Split

27 files
  1. .github/workflows/ci.yml +0 −14
  2. .github/workflows/format.yml +0 −28
  3. .oxfmtrc.json +1 −1
  4. .oxlintrc.jsonc +1 −1
  5. apps/cloud/package.json +1 −0
  6. apps/cloud/src/api.test.ts +235 −0
  7. apps/cloud/src/api.ts +17 −386
  8. apps/cloud/src/api/autumn.ts +115 −0
  9. apps/cloud/src/api/error-response.ts +33 −0
  10. apps/cloud/src/api/execution-usage.ts +24 −0
  11. apps/cloud/src/api/layers.ts +90 −0
  12. apps/cloud/src/api/protected.test.ts +72 −0
  13. apps/cloud/src/api/protected.ts +103 −0
  14. apps/cloud/src/api/router.ts +39 −0
  15. apps/cloud/src/auth/handlers.ts +6 −1
  16. apps/cloud/src/mcp-session.ts +1 −1
  17. apps/cloud/src/mcp.ts +1 −1
  18. apps/cloud/src/routeTree.gen.ts +159 −159
  19. apps/cloud/vitest.unit.config.ts +8 −0
  20. apps/local/src/server/mcp.ts +2 −5
  21. bun.lock +6 −5
  22. packages/plugins/google-discovery/src/api/group.ts +27 −9
  23. packages/plugins/google-discovery/src/api/handlers.test.ts +66 −0
  24. packages/plugins/google-discovery/src/api/handlers.ts +51 −10
  25. packages/plugins/mcp/src/api/group.ts +33 −12
  26. packages/plugins/mcp/src/api/handlers.test.ts +66 −0
  27. packages/plugins/mcp/src/api/handlers.ts +46 −13
.github/workflows/ci.yml+0−14
@@ -11,20 +11,6 @@ concurrency: · 10 unmodified lines
11 cancel-in-progress: true11 cancel-in-progress: true
12 12
13jobs:13jobs:
14 # format:
15 # name: Format
16 # runs-on: ubuntu-latest
17 # steps:
18 # - uses: actions/checkout@v4
19 #
20 # - uses: oven-sh/setup-bun@v2
21 # with:
22 # bun-version: 1.3.11
23 #
24 # - run: bun install --frozen-lockfile
25 #
26 # - run: bun run format:check
27
28 lint:14 lint:
29 name: Lint15 name: Lint
30 runs-on: ubuntu-latest16 runs-on: ubuntu-latest
.github/workflows/format.yml+0−28
@@ -1,28 +0,0 @@
1name: Format
2
3on:
4 pull_request:
5
6permissions:
7 contents: write
8
9jobs:
10 format:
11 name: Auto-format
12 runs-on: ubuntu-latest
13 steps:
14 - uses: actions/checkout@v4
15 with:
16 ref: ${{ github.head_ref }}
17
18 - uses: oven-sh/setup-bun@v2
19 with:
20 bun-version: 1.3.11
21
22 - run: bun install --frozen-lockfile
23
24 - run: bun run format
25
26 - uses: stefanzweifel/git-auto-commit-action@v5
27 with:
28 commit_message: "style: format"
.oxfmtrc.json+1−1
@@ -1,4 +1,4 @@
1{1{
2 "$schema": "./node_modules/oxfmt/configuration_schema.json",2 "$schema": "./node_modules/oxfmt/configuration_schema.json",
3 "ignorePatterns": []3 "ignorePatterns": ["**/routeTree.gen.ts"]
4}4}
.oxlintrc.jsonc+1−1
@@ -16,5 +16,5 @@ · 15 unmodified lines
16 },16 },
17 ],17 ],
18 },18 },
19 "ignorePatterns": [".astro/"],19 "ignorePatterns": [".astro/", "**/routeTree.gen.ts"],
20}20}
apps/cloud/package.json+1−0
@@ -61,6 +61,7 @@ · 60 unmodified lines
61 "devDependencies": {61 "devDependencies": {
62 "@cloudflare/vitest-pool-workers": "^0.14.3",62 "@cloudflare/vitest-pool-workers": "^0.14.3",
63 "@cloudflare/workers-types": "^4.20250620.0",63 "@cloudflare/workers-types": "^4.20250620.0",
64 "@effect/platform-node": "catalog:",
64 "@effect/vitest": "^0.29.0",65 "@effect/vitest": "^0.29.0",
65 "@electric-sql/pglite": "^0.4.3",66 "@electric-sql/pglite": "^0.4.3",
66 "@electric-sql/pglite-socket": "^0.1.3",67 "@electric-sql/pglite-socket": "^0.1.3",
apps/cloud/src/api.test.ts+235−0
@@ -0,0 +1,235 @@
1import {
2 HttpApi,
3 HttpApiBuilder,
4 HttpApiClient,
5 HttpApiEndpoint,
6 HttpApiGroup,
7 HttpApp,
8 HttpClient,
9 HttpServer,
10 HttpServerResponse,
11} from "@effect/platform";
12import { NodeHttpServer } from "@effect/platform-node";
13import { expect, layer } from "@effect/vitest";
14import { Effect, Layer, Schema } from "effect";
15
16import {
17 ApiRequestHandler,
18 AutumnRequestHandlerService,
19 NonProtectedRequestHandlerService,
20 ProtectedRequestHandlerService,
21 TeamRequestHandlerService,
22} from "./api/router";
23
24const SourceResponse = Schema.Struct({ source: Schema.String });
25
26const TeamGroup = HttpApiGroup.make("team").add(
27 HttpApiEndpoint.get("ping", "/team/ping").addSuccess(SourceResponse),
28);
29const TeamApi = HttpApi.make("teamApi").add(TeamGroup);
30const TeamHandlers = HttpApiBuilder.group(TeamApi, "team", (handlers) =>
31 handlers.handle("ping", () => Effect.succeed({ source: "team" })),
32);
33
34const AuthGroup = HttpApiGroup.make("auth").add(
35 HttpApiEndpoint.get("me", "/auth/me").addSuccess(SourceResponse),
36);
37const AuthApi = HttpApi.make("authApi").add(AuthGroup);
38const AuthHandlers = HttpApiBuilder.group(AuthApi, "auth", (handlers) =>
39 handlers.handle("me", () => Effect.succeed({ source: "auth" })),
40);
41
42const ProtectedGroup = HttpApiGroup.make("protected")
43 .add(HttpApiEndpoint.get("scope", "/scope").addSuccess(SourceResponse))
44 .add(
45 HttpApiEndpoint.post("resume", "/executions/:executionId/resume")
46 .setPath(Schema.Struct({ executionId: Schema.String }))
47 .addSuccess(SourceResponse),
48 );
49const ProtectedApi = HttpApi.make("protectedApi").add(ProtectedGroup);
50const ProtectedHandlers = HttpApiBuilder.group(ProtectedApi, "protected", (handlers) =>
51 handlers
52 .handle("scope", () => Effect.succeed({ source: "protected" }))
53 .handle("resume", () => Effect.succeed({ source: "protected" })),
54);
55
56const TeamTestApp = Effect.flatMap(
57 HttpApiBuilder.httpApp.pipe(
58 Effect.provide(
59 HttpApiBuilder.api(TeamApi).pipe(
60 Layer.provide(TeamHandlers),
61 Layer.provideMerge(HttpServer.layerContext),
62 Layer.provideMerge(HttpApiBuilder.Router.Live),
63 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
64 ),
65 ),
66 ),
67 (app) => app,
68).pipe(Effect.provide(HttpServer.layerContext));
69
70const AuthTestApp = Effect.flatMap(
71 HttpApiBuilder.httpApp.pipe(
72 Effect.provide(
73 HttpApiBuilder.api(AuthApi).pipe(
74 Layer.provide(AuthHandlers),
75 Layer.provideMerge(HttpServer.layerContext),
76 Layer.provideMerge(HttpApiBuilder.Router.Live),
77 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
78 ),
79 ),
80 ),
81 (app) => app,
82).pipe(Effect.provide(HttpServer.layerContext));
83
84const ProtectedBaseTestApp = Effect.flatMap(
85 HttpApiBuilder.httpApp.pipe(
86 Effect.provide(
87 HttpApiBuilder.api(ProtectedApi).pipe(
88 Layer.provide(ProtectedHandlers),
89 Layer.provideMerge(HttpServer.layerContext),
90 Layer.provideMerge(HttpApiBuilder.Router.Live),
91 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
92 ),
93 ),
94 ),
95 (app) => app,
96).pipe(Effect.provide(HttpServer.layerContext));
97
98type ProtectedMode = "ok" | "none" | "error" | "bad-status";
99
100const testState: {
101 mode: ProtectedMode;
102} = {
103 mode: "ok",
104};
105
106const resetState = () => {
107 testState.mode = "ok";
108};
109
110const ProtectedTestApp = Effect.gen(function* () {
111 if (testState.mode === "none") {
112 return HttpServerResponse.unsafeJson(
113 { error: "No organization in session", code: "no_organization" },
114 { status: 403 },
115 );
116 }
117 if (testState.mode === "error") {
118 return HttpServerResponse.unsafeJson({ error: "boom" }, { status: 500 });
119 }
120 if (testState.mode === "bad-status") {
121 return HttpServerResponse.unsafeJson({ source: "protected" }, { status: 400 });
122 }
123 return yield* ProtectedBaseTestApp;
124});
125
126const TestRequestHandlersLive = Layer.mergeAll(
127 Layer.succeed(TeamRequestHandlerService, { app: TeamTestApp }),
128 Layer.succeed(NonProtectedRequestHandlerService, { app: AuthTestApp }),
129 Layer.succeed(AutumnRequestHandlerService, {
130 app: Effect.succeed(HttpServerResponse.unsafeJson({ source: "autumn" })),
131 }),
132 Layer.succeed(ProtectedRequestHandlerService, { app: ProtectedTestApp }),
133);
134
135const requestHandler = Effect.runSync(Effect.provide(ApiRequestHandler, TestRequestHandlersLive));
136
137const TestApi = HttpApi.make("testApi")
138 .add(TeamGroup)
139 .add(AuthGroup)
140 .add(
141 HttpApiGroup.make("autumn").add(
142 HttpApiEndpoint.get("plans", "/autumn/plans").addSuccess(SourceResponse),
143 ),
144 )
145 .add(ProtectedGroup);
146
147const TestServerLayer = HttpServer.serve(HttpApp.fromWebHandler(requestHandler)).pipe(
148 Layer.provideMerge(NodeHttpServer.layerTest),
149);
150
151const getClient = () => HttpApiClient.make(TestApi);
152
153layer(TestServerLayer)("handleApiRequest", (it) => {
154 it.effect("routes /team/* to the team API handler", () =>
155 Effect.gen(function* () {
156 resetState();
157 const client = yield* getClient();
158 const result = yield* client.team.ping();
159 expect(result).toEqual({ source: "team" });
160 }),
161 );
162
163 it.effect("routes /auth/* to the auth API handler", () =>
164 Effect.gen(function* () {
165 resetState();
166 const client = yield* getClient();
167 const result = yield* client.auth.me();
168 expect(result).toEqual({ source: "auth" });
169 }),
170 );
171
172 it.effect("routes /autumn/* to the autumn handler", () =>
173 Effect.gen(function* () {
174 resetState();
175 const client = yield* getClient();
176 const result = yield* client.autumn.plans();
177 expect(result).toEqual({ source: "autumn" });
178 }),
179 );
180
181 it.effect("routes non-auth paths to protected handler", () =>
182 Effect.gen(function* () {
183 resetState();
184 const client = yield* getClient();
185 const result = yield* client.protected.scope();
186 expect(result).toEqual({ source: "protected" });
187 }),
188 );
189
190 it.effect("returns 403 when protected handler returns no organization", () =>
191 Effect.gen(function* () {
192 resetState();
193 testState.mode = "none";
194
195 const response = yield* HttpClient.get("/scope");
196 expect(response.status).toBe(403);
197 const body = yield* response.json;
198 expect(body).toEqual({
199 error: "No organization in session",
200 code: "no_organization",
201 });
202 }),
203 );
204
205 it.effect("routes resume paths to protected handler", () =>
206 Effect.gen(function* () {
207 resetState();
208 const client = yield* getClient();
209 const result = yield* client.protected.resume({ path: { executionId: "exec_1" } });
210 expect(result).toEqual({ source: "protected" });
211 }),
212 );
213
214 it.effect("returns protected response status as-is", () =>
215 Effect.gen(function* () {
216 resetState();
217 testState.mode = "bad-status";
218
219 const response = yield* HttpClient.post("/executions/exec_1/resume");
220 expect(response.status).toBe(400);
221 }),
222 );
223
224 it.effect("returns 500 JSON when protected request handling throws", () =>
225 Effect.gen(function* () {
226 resetState();
227 testState.mode = "error";
228
229 const response = yield* HttpClient.get("/scope");
230 expect(response.status).toBe(500);
231 const body = yield* response.json;
232 expect(body).toEqual({ error: "boom" });
233 }),
234 );
235});
apps/cloud/src/api.ts+17−386
@@ -1,391 +1,22 @@
1// ---------------------------------------------------------------------------
2// Cloud API — non-protected (auth) endpoints + org-protected executor endpoints
3// ---------------------------------------------------------------------------
4
5import { env } from "cloudflare:workers";
6import * as Sentry from "@sentry/cloudflare";
7import {
8 HttpApiBuilder,
9 HttpApiSwagger,
10 HttpMiddleware,
11 HttpRouter,
12 HttpServer,
13} from "@effect/platform";
14import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
15import { Autumn } from "autumn-js";2import { AutumnApiApp } from "./api/autumn";
16import { autumnHandler } from "autumn-js/backend";3import { NonProtectedApiApp, TeamApiApp } from "./api/layers";
17 4import { ProtectedApiApp } from "./api/protected";
18import { CoreExecutorApi } from "@executor/api";
19import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";
20import { createExecutionEngine } from "@executor/execution";
21import { makeDynamicWorkerExecutor, type CodeExecutor } from "@executor/runtime-dynamic-worker";
22import {
23 OpenApiGroup,
24 OpenApiExtensionService,
25 OpenApiHandlers,
26} from "@executor/plugin-openapi/api";
27import { McpGroup, McpExtensionService, McpHandlers } from "@executor/plugin-mcp/api";
28import {
29 GoogleDiscoveryGroup,
30 GoogleDiscoveryExtensionService,
31 GoogleDiscoveryHandlers,
32} from "@executor/plugin-google-discovery/api";
33import {
34 GraphqlGroup,
35 GraphqlExtensionService,
36 GraphqlHandlers,
37} from "@executor/plugin-graphql/api";
38
39import { OrgAuth } from "./auth/middleware";
40import { OrgAuthLive, SessionAuthLive } from "./auth/middleware-live";
41import { UserStoreService } from "./auth/context";
42import {5import {
43 CloudAuthPublicHandlers,6 ApiRequestHandler,
44 CloudSessionAuthHandlers,7 AutumnRequestHandlerService,
45 NonProtectedApi,8 NonProtectedRequestHandlerService,
46} from "./auth/handlers";9 ProtectedRequestHandlerService,
47import { WorkOSAuth } from "./auth/workos";10 TeamRequestHandlerService,
48import { DbService } from "./services/db";11} from "./api/router";
49import { createOrgExecutor } from "./services/executor";12
50import { TeamOrgApi } from "./team/compose";13const ApiRequestHandlersLive = Layer.mergeAll(
51import { TeamHandlers } from "./team/handlers";14 Layer.succeed(TeamRequestHandlerService, { app: TeamApiApp }),
52import { TelemetryLive } from "./services/telemetry";15 Layer.succeed(NonProtectedRequestHandlerService, { app: NonProtectedApiApp }),
53import { server } from "./env";16 Layer.succeed(AutumnRequestHandlerService, { app: AutumnApiApp }),
54 17 Layer.succeed(ProtectedRequestHandlerService, { app: ProtectedApiApp }),
55// ---------------------------------------------------------------------------
56// API definitions
57// ---------------------------------------------------------------------------
58
59/** Protected (org-required) API — all the executor groups + OrgAuth middleware */
60const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
61 .add(McpGroup)
62 .add(GoogleDiscoveryGroup)
63 .add(GraphqlGroup)
64 .middleware(OrgAuth);
65
66// ---------------------------------------------------------------------------
67// Layers
68// ---------------------------------------------------------------------------
69
70const DbLive = DbService.Live;
71const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
72
73const SharedServices = Layer.mergeAll(
74 DbLive,
75 UserStoreLive,
76 WorkOSAuth.Default,
77 HttpServer.layerContext,
78).pipe(Layer.provideMerge(TelemetryLive));
79const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe(
80 Layer.provide(
81 Layer.mergeAll(
82 CoreHandlers,
83 OpenApiHandlers,
84 McpHandlers,
85 GoogleDiscoveryHandlers,
86 GraphqlHandlers,
87 OrgAuthLive,
88 ),
89 ),
90);
91
92const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe(
93 Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
94 Layer.provideMerge(SessionAuthLive),
95);18);
96 19
97const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe(20export const handleApiRequest = Effect.runSync(
98 Layer.provide(TeamHandlers),21 Effect.provide(ApiRequestHandler, ApiRequestHandlersLive),
99 Layer.provideMerge(OrgAuthLive),
100);22);
101
102// ---------------------------------------------------------------------------
103// Public auth web handler
104// ---------------------------------------------------------------------------
105//
106// Build per-request, not once at module load. `toWebHandler` creates a
107// single long-lived `Layer.MemoMap` that memoizes `DbService.Live`'s
108// `Layer.scoped` acquire — the resulting `sql` connection is created in
109// the module scope, not the request scope. Workerd tears down TCP sockets
110// at request boundaries, so the second request on a cached handler fails
111// with "Cannot perform I/O on behalf of a different request". Building a
112// fresh handler per request gives each request its own layer scope and a
113// fresh socket. Auth endpoints (login/callback/me/logout) are infrequent
114// so the overhead is negligible.
115// ---------------------------------------------------------------------------
116
117const RouterConfig = HttpRouter.setRouterConfig({ maxParamLength: 1000 });
118
119const createNonProtectedHandler = () =>
120 HttpApiBuilder.toWebHandler(
121 NonProtectedApiLive.pipe(Layer.provideMerge(SharedServices), Layer.provideMerge(RouterConfig)),
122 { middleware: HttpMiddleware.logger },
123 );
124
125const createTeamHandler = () =>
126 HttpApiBuilder.toWebHandler(
127 TeamApiLive.pipe(Layer.provideMerge(SharedServices), Layer.provideMerge(RouterConfig)),
128 { middleware: HttpMiddleware.logger },
129 );
130
131// ---------------------------------------------------------------------------
132// Protected handler — must be built per-request because the executor varies
133// ---------------------------------------------------------------------------
134
135const buildProtectedHandler = (
136 organizationId: string,
137 organizationName: string,
138 codeExecutor: CodeExecutor,
139) =>
140 Effect.gen(function* () {
141 const executor = yield* createOrgExecutor(
142 organizationId,
143 organizationName,
144 server.ENCRYPTION_KEY,
145 );
146
147 const engine = createExecutionEngine({ executor, codeExecutor });
148
149 const requestServices = Layer.mergeAll(
150 Layer.succeed(ExecutorService, executor),
151 Layer.succeed(ExecutionEngineService, engine),
152 Layer.succeed(OpenApiExtensionService, executor.openapi),
153 Layer.succeed(McpExtensionService, executor.mcp),
154 Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery),
155 Layer.succeed(GraphqlExtensionService, executor.graphql),
156 );
157
158 return HttpApiBuilder.toWebHandler(
159 HttpApiSwagger.layer({ path: "/docs" }).pipe(
160 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
161 Layer.provideMerge(ProtectedCloudApiLive),
162 Layer.provideMerge(requestServices),
163 Layer.provideMerge(SharedServices),
164 Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),
165 ),
166 { middleware: HttpMiddleware.logger },
167 );
168 });
169
170// ---------------------------------------------------------------------------
171// Routing
172// ---------------------------------------------------------------------------
173
174const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/");
175const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/");
176const isTeamPath = (pathname: string): boolean => pathname.startsWith("/team/");
177const isExecutionPath = (pathname: string): boolean =>
178 pathname === "/executions" || /^\/executions\/[^/]+\/resume$/.test(pathname);
179
180// ---------------------------------------------------------------------------
181// Autumn billing — lazy-initialized SDK for fire-and-forget tracking
182// ---------------------------------------------------------------------------
183
184let _autumn: Autumn | null = null;
185const getAutumn = () => {
186 if (!_autumn && server.AUTUMN_SECRET_KEY) {
187 _autumn = new Autumn({ secretKey: server.AUTUMN_SECRET_KEY });
188 }
189 return _autumn;
190};
191
192/**
193 * Resolve the user's organization for executor creation. Reads from the
194 * session cookie via WorkOS — returns null if there's no session or no
195 * organization yet, so the caller can return a 403.
196 */
197const lookupOrgForRequest = (request: Request) =>
198 Effect.gen(function* () {
199 const workos = yield* WorkOSAuth;
200 const result = yield* workos.authenticateRequest(request);
201 if (!result || !result.organizationId) return null;
202 const users = yield* UserStoreService;
203 return yield* users.use((s) => s.getOrganization(result.organizationId!));
204 });
205
206// ---------------------------------------------------------------------------
207// Autumn billing proxy — authenticates the session, then forwards to Autumn
208// ---------------------------------------------------------------------------
209
210const handleAutumnRequest = async (request: Request): Promise<Response> => {
211 const program = Effect.gen(function* () {
212 const workos = yield* WorkOSAuth;
213 const result = yield* workos.authenticateRequest(request);
214
215 if (!result || !result.organizationId) {
216 return Response.json({ error: "Unauthorized" }, { status: 401 });
217 }
218
219 const url = new URL(request.url);
220 const body =
221 request.method !== "GET" && request.method !== "HEAD"
222 ? yield* Effect.promise(() => request.json())
223 : undefined;
224
225 const { statusCode, response } = yield* Effect.promise(() =>
226 autumnHandler({
227 request: {
228 url: url.pathname,
229 method: request.method,
230 body,
231 },
232 customerId: result.organizationId,
233 customerData: {
234 name: result.email,
235 email: result.email,
236 },
237 clientOptions: {
238 secretKey: server.AUTUMN_SECRET_KEY,
239 },
240 pathPrefix: "/autumn",
241 }),
242 );
243
244 if (statusCode >= 400) {
245 console.error("[autumn] upstream error:", statusCode, response);
246 return Response.json({ error: "Billing request failed" }, { status: statusCode });
247 }
248
249 return Response.json(response, { status: statusCode });
250 });
251
252 return Effect.runPromise(program.pipe(Effect.provide(SharedServices), Effect.scoped)).catch(
253 (err) => {
254 Sentry.captureException(err);
255 console.error("[autumn] request failed:", err instanceof Error ? err.stack : err);
256 return Response.json({ error: "Internal server error" }, { status: 500 });
257 },
258 );
259};
260
261// ---------------------------------------------------------------------------
262// Widget token endpoint — returns a WorkOS widget token for the session user
263// ---------------------------------------------------------------------------
264
265// ---------------------------------------------------------------------------
266// Sentry tunnel — proxies client events to Sentry to bypass ad blockers
267// ---------------------------------------------------------------------------
268
269const SENTRY_HOST = "o4511196985556992.ingest.us.sentry.io";
270const SENTRY_PROJECT_IDS = new Set(["4511197001416704", "4511197010067456"]);
271
272const handleSentryTunnel = async (request: Request): Promise<Response> => {
273 try {
274 const body = await request.text();
275 const header = body.split("\n")[0];
276 const { dsn } = JSON.parse(header) as { dsn: string };
277 const url = new URL(dsn);
278 const projectId = url.pathname.replace("/", "");
279
280 if (url.host !== SENTRY_HOST || !SENTRY_PROJECT_IDS.has(projectId)) {
281 return new Response("Invalid Sentry DSN", { status: 400 });
282 }
283
284 return fetch(`https://${SENTRY_HOST}/api/${projectId}/envelope/`, {
285 method: "POST",
286 body,
287 });
288 } catch {
289 return new Response("Invalid request", { status: 400 });
290 }
291};
292
293export const handleApiRequest = async (request: Request): Promise<Response> => {
294 const pathname = new URL(request.url).pathname;
295
296 if (pathname === "/sentry-tunnel" && request.method === "POST") {
297 return handleSentryTunnel(request);
298 }
299
300 if (isTeamPath(pathname)) {
301 const handler = createTeamHandler();
302 try {
303 return await handler.handler(request);
304 } finally {
305 await handler.dispose();
306 }
307 }
308
309 if (isAutumnPath(pathname)) {
310 return handleAutumnRequest(request);
311 }
312
313 if (isAuthPath(pathname)) {
314 const handler = createNonProtectedHandler();
315 try {
316 return await handler.handler(request);
317 } finally {
318 await handler.dispose();
319 }
320 }
321
322 // Protected path — build the executor lazily for the request's org.
323 try {
324 const program = Effect.gen(function* () {
325 const org = yield* lookupOrgForRequest(request);
326 if (!org) return null;
327
328 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
329 const handler = yield* buildProtectedHandler(org.id, org.name, codeExecutor);
330 const response = yield* Effect.promise(() => handler.handler(request));
331
332 // Sanitize error responses — only let declared API errors through,
333 // replace anything else with a generic message to prevent leaking internals
334 if (!response.ok) {
335 const sanitized = yield* Effect.promise(async () => {
336 try {
337 const body = await response.clone().json();
338 const hasTag = body && typeof body === "object" && "_tag" in body;
339 if (!hasTag) {
340 Sentry.captureMessage(`API ${response.status}: ${pathname}`, { level: "error" });
341 return Response.json(
342 { error: "Internal server error" },
343 { status: response.status >= 500 ? 500 : response.status },
344 );
345 }
346 } catch {
347 Sentry.captureMessage(`API ${response.status}: ${pathname}`, { level: "error" });
348 return Response.json({ error: "Internal server error" }, { status: 500 });
349 }
350 return null;
351 });
352 if (sanitized) return { response: sanitized, orgId: org.id };
353 }
354
355 return { response, orgId: org.id };
356 });
357
358 const result = await Effect.runPromise(
359 program.pipe(Effect.provide(SharedServices), Effect.scoped),
360 );
361
362 if (result === null) {
363 return Response.json(
364 { error: "No organization in session", code: "no_organization" },
365 { status: 403 },
366 );
367 }
368
369 // Fire-and-forget: track execution usage
370 if (isExecutionPath(pathname) && result.response.ok) {
371 const autumn = getAutumn();
372 if (autumn) {
373 autumn
374 .track({
375 customerId: result.orgId,
376 featureId: "executions",
377 value: 1,
378 })
379 .catch((err) => {
380 console.error("[billing] track failed:", err);
381 });
382 }
383 }
384
385 return result.response;
386 } catch (err) {
387 Sentry.captureException(err);
388 console.error("[api] request failed:", err instanceof Error ? err.stack : err);
389 return Response.json({ error: "Internal server error" }, { status: 500 });
390 }
391};
apps/cloud/src/api/autumn.ts+115−0
@@ -0,0 +1,115 @@
1import { Effect } from "effect";
2import { HttpServerRequest, HttpServerResponse } from "@effect/platform";
3import { Autumn } from "autumn-js";
4import { autumnHandler } from "autumn-js/backend";
5
6import { WorkOSAuth } from "../auth/workos";
7import { server } from "../env";
8import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
9import { SharedServices } from "./layers";
10
11let cachedAutumn: Autumn | null = null;
12
13const getAutumn = () => {
14 if (!cachedAutumn && server.AUTUMN_SECRET_KEY) {
15 cachedAutumn = new Autumn({ secretKey: server.AUTUMN_SECRET_KEY });
16 }
17 return cachedAutumn;
18};
19
20export const trackExecutionUsage = (organizationId: string): void => {
21 const autumn = getAutumn();
22 if (!autumn) return;
23
24 autumn
25 .track({
26 customerId: organizationId,
27 featureId: "executions",
28 value: 1,
29 })
30 .catch((err) => {
31 console.error("[billing] track failed:", err);
32 });
33};
34
35const handleAutumnRequestEffect = Effect.gen(function* () {
36 const request = yield* HttpServerRequest.HttpServerRequest;
37 const webRequest = yield* Effect.mapError(
38 HttpServerRequest.toWeb(request),
39 () =>
40 new HttpResponseError({
41 status: 500,
42 code: "invalid_request",
43 message: "Invalid request",
44 }),
45 );
46
47 const workos = yield* WorkOSAuth;
48 const session = yield* workos.authenticateRequest(webRequest);
49
50 if (!session || !session.organizationId) {
51 return yield* Effect.fail(
52 new HttpResponseError({
53 status: 401,
54 code: "unauthorized",
55 message: "Unauthorized",
56 }),
57 );
58 }
59
60 const url = new URL(webRequest.url);
61 const body =
62 request.method !== "GET" && request.method !== "HEAD"
63 ? yield* Effect.mapError(
64 request.json,
65 () =>
66 new HttpResponseError({
67 status: 400,
68 code: "invalid_json",
69 message: "Invalid request body",
70 }),
71 )
72 : undefined;
73
74 const { statusCode, response } = yield* Effect.promise(() =>
75 autumnHandler({
76 request: {
77 url: url.pathname,
78 method: request.method,
79 body,
80 },
81 customerId: session.organizationId,
82 customerData: {
83 name: session.email,
84 email: session.email,
85 },
86 clientOptions: {
87 secretKey: server.AUTUMN_SECRET_KEY,
88 },
89 pathPrefix: "/autumn",
90 }),
91 );
92
93 if (statusCode >= 400) {
94 console.error("[autumn] upstream error:", statusCode, response);
95 return yield* Effect.fail(
96 new HttpResponseError({
97 status: statusCode,
98 code: "billing_request_failed",
99 message: "Billing request failed",
100 }),
101 );
102 }
103
104 return HttpServerResponse.unsafeJson(response, { status: statusCode });
105}).pipe(
106 Effect.provide(SharedServices),
107 Effect.catchAll((err) => {
108 if (isServerError(err)) {
109 console.error("[autumn] request failed:", err instanceof Error ? err.stack : err);
110 }
111 return Effect.succeed(toErrorServerResponse(err));
112 }),
113);
114
115export const AutumnApiApp = handleAutumnRequestEffect;
apps/cloud/src/api/error-response.ts+33−0
@@ -0,0 +1,33 @@
1import { Data } from "effect";
2import { HttpServerResponse } from "@effect/platform";
3
4export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{
5 readonly status: number;
6 readonly code: string;
7 readonly message: string;
8}> {}
9
10const toHttpResponseError = (error: unknown): HttpResponseError =>
11 error instanceof HttpResponseError
12 ? error
13 : new HttpResponseError({
14 status: 500,
15 code: "internal_server_error",
16 message: "Internal server error",
17 });
18
19export const isServerError = (error: unknown): boolean => toHttpResponseError(error).status >= 500;
20
21export const toErrorResponse = (error: unknown): Response => {
22 const mapped = toHttpResponseError(error);
23 return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status });
24};
25
26export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {
27 console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error);
28 const mapped = toHttpResponseError(error);
29 return HttpServerResponse.unsafeJson(
30 { error: mapped.message, code: mapped.code },
31 { status: mapped.status },
32 );
33};
apps/cloud/src/api/execution-usage.ts+24−0
@@ -0,0 +1,24 @@
1import type { ExecutionEngine } from "@executor/execution";
2
3export const withExecutionUsageTracking = (
4 organizationId: string,
5 engine: ExecutionEngine,
6 trackUsage: (organizationId: string) => void,
7): ExecutionEngine => ({
8 execute: async (code, options) => {
9 const result = await engine.execute(code, options);
10 trackUsage(organizationId);
11 return result;
12 },
13 executeWithPause: async (code) => {
14 const result = await engine.executeWithPause(code);
15 trackUsage(organizationId);
16 return result;
17 },
18 resume: async (executionId, response) => {
19 const result = await engine.resume(executionId, response);
20 // resume doesn't count as usage
21 return result;
22 },
23 getDescription: engine.getDescription,
24});
apps/cloud/src/api/layers.ts+90−0
@@ -0,0 +1,90 @@
1import { HttpApiBuilder, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform";
2import { Effect, Layer } from "effect";
3
4import { CoreExecutorApi } from "@executor/api";
5import { CoreHandlers } from "@executor/api/server";
6import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";
7import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";
8import {
9 GoogleDiscoveryGroup,
10 GoogleDiscoveryHandlers,
11} from "@executor/plugin-google-discovery/api";
12import { GraphqlGroup, GraphqlHandlers } from "@executor/plugin-graphql/api";
13
14import { OrgAuth } from "../auth/middleware";
15import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";
16import { UserStoreService } from "../auth/context";
17import {
18 CloudAuthPublicHandlers,
19 CloudSessionAuthHandlers,
20 NonProtectedApi,
21} from "../auth/handlers";
22import { WorkOSAuth } from "../auth/workos";
23import { DbService } from "../services/db";
24import { TeamOrgApi } from "../team/compose";
25import { TeamHandlers } from "../team/handlers";
26
27const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
28 .add(McpGroup)
29 .add(GoogleDiscoveryGroup)
30 .add(GraphqlGroup)
31 .middleware(OrgAuth);
32
33const DbLive = DbService.Live;
34const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
35
36export const SharedServices = Layer.mergeAll(
37 DbLive,
38 UserStoreLive,
39 WorkOSAuth.Default,
40 HttpServer.layerContext,
41);
42
43export const RouterConfig = HttpRouter.setRouterConfig({ maxParamLength: 1000 });
44
45export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe(
46 Layer.provide(
47 Layer.mergeAll(
48 CoreHandlers,
49 OpenApiHandlers,
50 McpHandlers,
51 GoogleDiscoveryHandlers,
52 GraphqlHandlers,
53 OrgAuthLive,
54 ),
55 ),
56);
57
58const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe(
59 Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
60 Layer.provideMerge(SessionAuthLive),
61);
62
63const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe(
64 Layer.provide(TeamHandlers),
65 Layer.provideMerge(OrgAuthLive),
66);
67
68const NonProtectedRequestLayer = NonProtectedApiLive.pipe(
69 Layer.provideMerge(RouterConfig),
70 Layer.provideMerge(HttpServer.layerContext),
71 Layer.provideMerge(HttpApiBuilder.Router.Live),
72 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
73);
74
75const TeamRequestLayer = TeamApiLive.pipe(
76 Layer.provideMerge(RouterConfig),
77 Layer.provideMerge(HttpServer.layerContext),
78 Layer.provideMerge(HttpApiBuilder.Router.Live),
79 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
80);
81
82export const NonProtectedApiApp = Effect.flatMap(
83 HttpApiBuilder.httpApp.pipe(Effect.provide(NonProtectedRequestLayer)),
84 HttpMiddleware.logger,
85).pipe(Effect.provide(SharedServices));
86
87export const TeamApiApp = Effect.flatMap(
88 HttpApiBuilder.httpApp.pipe(Effect.provide(TeamRequestLayer)),
89 HttpMiddleware.logger,
90).pipe(Effect.provide(SharedServices));
apps/cloud/src/api/protected.test.ts+72−0
@@ -0,0 +1,72 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import type { ExecutionEngine } from "@executor/execution";
4import { withExecutionUsageTracking } from "./execution-usage";
5
6const makeBaseEngine = (): ExecutionEngine =>
7 ({
8 execute: async () => ({ result: "ok", logs: [] }),
9 executeWithPause: async () => ({
10 status: "completed",
11 result: { result: "ok", logs: [] },
12 }),
13 resume: async () => ({
14 status: "completed",
15 result: { result: "ok", logs: [] },
16 }),
17 getDescription: async () => "desc",
18 }) as ExecutionEngine;
19
20describe("withExecutionUsageTracking", () => {
21 it.effect("tracks successful execute and executeWithPause", () =>
22 Effect.gen(function* () {
23 const tracked: string[] = [];
24 const engine = withExecutionUsageTracking("org_1", makeBaseEngine(), (orgId) => {
25 tracked.push(orgId);
26 });
27
28 yield* Effect.promise(() =>
29 engine.execute("1+1", { onElicitation: (() => Effect.die("unused")) as never }),
30 );
31 yield* Effect.promise(() => engine.executeWithPause("2+2"));
32
33 expect(tracked).toEqual(["org_1", "org_1"]);
34 }),
35 );
36
37 it.effect("does not track resume usage", () =>
38 Effect.gen(function* () {
39 const tracked: string[] = [];
40 const base = makeBaseEngine();
41
42 let shouldReturnNull = false;
43 const engine = withExecutionUsageTracking(
44 "org_2",
45 {
46 ...base,
47 resume: async (...args) => {
48 if (shouldReturnNull) return null;
49 return base.resume(...args);
50 },
51 },
52 (orgId) => {
53 tracked.push(orgId);
54 },
55 );
56
57 yield* Effect.promise(() =>
58 engine.resume("exec_1", {
59 action: "accept",
60 }),
61 );
62 shouldReturnNull = true;
63 yield* Effect.promise(() =>
64 engine.resume("missing", {
65 action: "accept",
66 }),
67 );
68
69 expect(tracked).toEqual([]);
70 }),
71 );
72});
apps/cloud/src/api/protected.ts+103−0
@@ -0,0 +1,103 @@
1import { env } from "cloudflare:workers";
2import { HttpApiBuilder, HttpApiSwagger, HttpServerRequest } from "@effect/platform";
3import { Effect, Layer } from "effect";
4
5import { ExecutorService, ExecutionEngineService } from "@executor/api/server";
6import { createExecutionEngine } from "@executor/execution";
7import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker";
8import { OpenApiExtensionService } from "@executor/plugin-openapi/api";
9import { McpExtensionService } from "@executor/plugin-mcp/api";
10import { GoogleDiscoveryExtensionService } from "@executor/plugin-google-discovery/api";
11import { GraphqlExtensionService } from "@executor/plugin-graphql/api";
12
13import { UserStoreService } from "../auth/context";
14import { WorkOSAuth } from "../auth/workos";
15import { server } from "../env";
16import { createOrgExecutor } from "../services/executor";
17import { trackExecutionUsage } from "./autumn";
18import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
19import { withExecutionUsageTracking } from "./execution-usage";
20import { ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers";
21
22const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) =>
23 Effect.gen(function* () {
24 const webRequest = yield* Effect.mapError(
25 HttpServerRequest.toWeb(request),
26 () =>
27 new HttpResponseError({
28 status: 500,
29 code: "invalid_request",
30 message: "Invalid request",
31 }),
32 );
33 const workos = yield* WorkOSAuth;
34 const session = yield* workos.authenticateRequest(webRequest);
35 if (!session || !session.organizationId) return null;
36
37 const users = yield* UserStoreService;
38 return yield* users.use((s) => s.getOrganization(session.organizationId!));
39 });
40
41const createProtectedApp = (organizationId: string, organizationName: string) =>
42 Effect.gen(function* () {
43 const executor = yield* createOrgExecutor(
44 organizationId,
45 organizationName,
46 server.ENCRYPTION_KEY,
47 );
48 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
49 const engine = withExecutionUsageTracking(
50 organizationId,
51 createExecutionEngine({ executor, codeExecutor }),
52 trackExecutionUsage,
53 );
54
55 const requestServices = Layer.mergeAll(
56 Layer.succeed(ExecutorService, executor),
57 Layer.succeed(ExecutionEngineService, engine),
58 Layer.succeed(OpenApiExtensionService, executor.openapi),
59 Layer.succeed(McpExtensionService, executor.mcp),
60 Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery),
61 Layer.succeed(GraphqlExtensionService, executor.graphql),
62 );
63
64 return yield* HttpApiBuilder.httpApp.pipe(
65 Effect.provide(
66 HttpApiSwagger.layer({ path: "/docs" }).pipe(
67 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
68 Layer.provideMerge(ProtectedCloudApiLive),
69 Layer.provideMerge(requestServices),
70 Layer.provideMerge(RouterConfig),
71 Layer.provideMerge(HttpApiBuilder.Router.Live),
72 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
73 ),
74 ),
75 );
76 });
77
78const handleProtectedRequestEffect = Effect.gen(function* () {
79 const request = yield* HttpServerRequest.HttpServerRequest;
80 const org = yield* lookupOrgForRequest(request);
81 if (!org) {
82 return yield* Effect.fail(
83 new HttpResponseError({
84 status: 403,
85 code: "no_organization",
86 message: "No organization in session",
87 }),
88 );
89 }
90
91 const app = yield* createProtectedApp(org.id, org.name);
92 return yield* app;
93}).pipe(
94 Effect.provide(SharedServices),
95 Effect.catchAll((err) => {
96 if (isServerError(err)) {
97 console.error("[api] request failed:", err instanceof Error ? err.stack : err);
98 }
99 return Effect.succeed(toErrorServerResponse(err));
100 }),
101);
102
103export const ProtectedApiApp = handleProtectedRequestEffect;
apps/cloud/src/api/router.ts+39−0
@@ -0,0 +1,39 @@
1import { HttpApp, HttpRouter } from "@effect/platform";
2import { Context, Effect } from "effect";
3
4type RequestAppService = {
5 readonly app: HttpApp.Default;
6};
7
8export class TeamRequestHandlerService extends Context.Tag(
9 "@executor/cloud/TeamRequestHandlerService",
10)<TeamRequestHandlerService, RequestAppService>() {}
11
12export class NonProtectedRequestHandlerService extends Context.Tag(
13 "@executor/cloud/NonProtectedRequestHandlerService",
14)<NonProtectedRequestHandlerService, RequestAppService>() {}
15
16export class AutumnRequestHandlerService extends Context.Tag(
17 "@executor/cloud/AutumnRequestHandlerService",
18)<AutumnRequestHandlerService, RequestAppService>() {}
19
20export class ProtectedRequestHandlerService extends Context.Tag(
21 "@executor/cloud/ProtectedRequestHandlerService",
22)<ProtectedRequestHandlerService, RequestAppService>() {}
23
24export const ApiRouterApp = Effect.gen(function* () {
25 const team = yield* TeamRequestHandlerService;
26 const nonProtected = yield* NonProtectedRequestHandlerService;
27 const autumn = yield* AutumnRequestHandlerService;
28 const protectedHandler = yield* ProtectedRequestHandlerService;
29
30 return yield* HttpRouter.empty.pipe(
31 HttpRouter.mountApp("/team", team.app, { includePrefix: true }),
32 HttpRouter.mountApp("/auth", nonProtected.app, { includePrefix: true }),
33 HttpRouter.mountApp("/autumn", autumn.app, { includePrefix: true }),
34 HttpRouter.mountApp("/", protectedHandler.app),
35 HttpRouter.toHttpApp,
36 );
37});
38
39export const ApiRequestHandler = Effect.map(ApiRouterApp, HttpApp.toWebHandler);
apps/cloud/src/auth/handlers.ts+6−1
@@ -87,7 +87,12 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 86 unmodified lines
87 87
88 setCookie("wos-session", sealedSession, COOKIE_OPTIONS);88 setCookie("wos-session", sealedSession, COOKIE_OPTIONS);
89 return HttpServerResponse.redirect("/", { status: 302 });89 return HttpServerResponse.redirect("/", { status: 302 });
90 }),90 }).pipe(
91 Effect.catchTags({
92 WorkOSError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
93 UserStoreError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
94 }),
95 ),
91 ),96 ),
92);97);
93 98
apps/cloud/src/mcp-session.ts+1−1
@@ -143,7 +143,7 @@ export class McpSessionDO extends DurableObject { · 142 unmodified lines
143 return await this.transport.handleRequest(request);143 return await this.transport.handleRequest(request);
144 } catch (err) {144 } catch (err) {
145 console.error("[mcp-session] handleRequest error:", err instanceof Error ? err.stack : err);145 console.error("[mcp-session] handleRequest error:", err instanceof Error ? err.stack : err);
146 return jsonRpcError(500, -32603, err instanceof Error ? err.message : "Internal error");146 return jsonRpcError(500, -32603, "Internal error");
147 }147 }
148 }148 }
149 149
apps/cloud/src/mcp.ts+1−1
@@ -128,7 +128,7 @@ const handleMcpRequest_POST = async (request: Request, token: VerifiedToken): Pr · 127 unmodified lines
128 return await stub.handleRequest(request);128 return await stub.handleRequest(request);
129 } catch (err) {129 } catch (err) {
130 console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err);130 console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err);
131 return jsonRpcError(500, -32603, err instanceof Error ? err.message : "Internal server error");131 return jsonRpcError(500, -32603, "Internal server error");
132 }132 }
133};133};
134 134
apps/cloud/src/routeTree.gen.ts+159−159
@@ -8,190 +8,190 @@ · 7 unmodified lines
8// You should NOT make any changes in this file as it will be overwritten.8// You should NOT make any changes in this file as it will be overwritten.
9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10 10
11import { Route as rootRouteImport } from "./routes/__root";11import { Route as rootRouteImport } from './routes/__root'
12import { Route as ToolsRouteImport } from "./routes/tools";12import { Route as ToolsRouteImport } from './routes/tools'
13import { Route as TeamRouteImport } from "./routes/team";13import { Route as TeamRouteImport } from './routes/team'
14import { Route as SecretsRouteImport } from "./routes/secrets";14import { Route as SecretsRouteImport } from './routes/secrets'
15import { Route as BillingRouteImport } from "./routes/billing";15import { Route as BillingRouteImport } from './routes/billing'
16import { Route as IndexRouteImport } from "./routes/index";16import { Route as IndexRouteImport } from './routes/index'
17import { Route as SourcesNamespaceRouteImport } from "./routes/sources.$namespace";17import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'
18import { Route as BillingPlansRouteImport } from "./routes/billing_.plans";18import { Route as BillingPlansRouteImport } from './routes/billing_.plans'
19import { Route as SourcesAddPluginKeyRouteImport } from "./routes/sources.add.$pluginKey";19import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey'
20 20
21const ToolsRoute = ToolsRouteImport.update({21const ToolsRoute = ToolsRouteImport.update({
22 id: "/tools",22 id: '/tools',
23 path: "/tools",23 path: '/tools',
24 getParentRoute: () => rootRouteImport,24 getParentRoute: () => rootRouteImport,
25} as any);25} as any)
26const TeamRoute = TeamRouteImport.update({26const TeamRoute = TeamRouteImport.update({
27 id: "/team",27 id: '/team',
28 path: "/team",28 path: '/team',
29 getParentRoute: () => rootRouteImport,29 getParentRoute: () => rootRouteImport,
30} as any);30} as any)
31const SecretsRoute = SecretsRouteImport.update({31const SecretsRoute = SecretsRouteImport.update({
32 id: "/secrets",32 id: '/secrets',
33 path: "/secrets",33 path: '/secrets',
34 getParentRoute: () => rootRouteImport,34 getParentRoute: () => rootRouteImport,
35} as any);35} as any)
36const BillingRoute = BillingRouteImport.update({36const BillingRoute = BillingRouteImport.update({
37 id: "/billing",37 id: '/billing',
38 path: "/billing",38 path: '/billing',
39 getParentRoute: () => rootRouteImport,39 getParentRoute: () => rootRouteImport,
40} as any);40} as any)
41const IndexRoute = IndexRouteImport.update({41const IndexRoute = IndexRouteImport.update({
42 id: "/",42 id: '/',
43 path: "/",43 path: '/',
44 getParentRoute: () => rootRouteImport,44 getParentRoute: () => rootRouteImport,
45} as any);45} as any)
46const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({46const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({
47 id: "/sources/$namespace",47 id: '/sources/$namespace',
48 path: "/sources/$namespace",48 path: '/sources/$namespace',
49 getParentRoute: () => rootRouteImport,49 getParentRoute: () => rootRouteImport,
50} as any);50} as any)
51const BillingPlansRoute = BillingPlansRouteImport.update({51const BillingPlansRoute = BillingPlansRouteImport.update({
52 id: "/billing_/plans",52 id: '/billing_/plans',
53 path: "/billing/plans",53 path: '/billing/plans',
54 getParentRoute: () => rootRouteImport,54 getParentRoute: () => rootRouteImport,
55} as any);55} as any)
56const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({56const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({
57 id: "/sources/add/$pluginKey",57 id: '/sources/add/$pluginKey',
58 path: "/sources/add/$pluginKey",58 path: '/sources/add/$pluginKey',
59 getParentRoute: () => rootRouteImport,59 getParentRoute: () => rootRouteImport,
60} as any);60} as any)
61 61
62export interface FileRoutesByFullPath {62export interface FileRoutesByFullPath {
63 "/": typeof IndexRoute;63 '/': typeof IndexRoute
64 "/billing": typeof BillingRoute;64 '/billing': typeof BillingRoute
65 "/secrets": typeof SecretsRoute;65 '/secrets': typeof SecretsRoute
66 "/team": typeof TeamRoute;66 '/team': typeof TeamRoute
67 "/tools": typeof ToolsRoute;67 '/tools': typeof ToolsRoute
68 "/billing/plans": typeof BillingPlansRoute;68 '/billing/plans': typeof BillingPlansRoute
69 "/sources/$namespace": typeof SourcesNamespaceRoute;69 '/sources/$namespace': typeof SourcesNamespaceRoute
70 "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute;70 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
71}71}
72export interface FileRoutesByTo {72export interface FileRoutesByTo {
73 "/": typeof IndexRoute;73 '/': typeof IndexRoute
74 "/billing": typeof BillingRoute;74 '/billing': typeof BillingRoute
75 "/secrets": typeof SecretsRoute;75 '/secrets': typeof SecretsRoute
76 "/team": typeof TeamRoute;76 '/team': typeof TeamRoute
77 "/tools": typeof ToolsRoute;77 '/tools': typeof ToolsRoute
78 "/billing/plans": typeof BillingPlansRoute;78 '/billing/plans': typeof BillingPlansRoute
79 "/sources/$namespace": typeof SourcesNamespaceRoute;79 '/sources/$namespace': typeof SourcesNamespaceRoute
80 "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute;80 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
81}81}
82export interface FileRoutesById {82export interface FileRoutesById {
83 __root__: typeof rootRouteImport;83 __root__: typeof rootRouteImport
84 "/": typeof IndexRoute;84 '/': typeof IndexRoute
85 "/billing": typeof BillingRoute;85 '/billing': typeof BillingRoute
86 "/secrets": typeof SecretsRoute;86 '/secrets': typeof SecretsRoute
87 "/team": typeof TeamRoute;87 '/team': typeof TeamRoute
88 "/tools": typeof ToolsRoute;88 '/tools': typeof ToolsRoute
89 "/billing_/plans": typeof BillingPlansRoute;89 '/billing_/plans': typeof BillingPlansRoute
90 "/sources/$namespace": typeof SourcesNamespaceRoute;90 '/sources/$namespace': typeof SourcesNamespaceRoute
91 "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute;91 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
92}92}
93export interface FileRouteTypes {93export interface FileRouteTypes {
94 fileRoutesByFullPath: FileRoutesByFullPath;94 fileRoutesByFullPath: FileRoutesByFullPath
95 fullPaths:95 fullPaths:
96 | "/"96 | '/'
97 | "/billing"97 | '/billing'
98 | "/secrets"98 | '/secrets'
99 | "/team"99 | '/team'
100 | "/tools"100 | '/tools'
101 | "/billing/plans"101 | '/billing/plans'
102 | "/sources/$namespace"102 | '/sources/$namespace'
103 | "/sources/add/$pluginKey";103 | '/sources/add/$pluginKey'
104 fileRoutesByTo: FileRoutesByTo;104 fileRoutesByTo: FileRoutesByTo
105 to:105 to:
106 | "/"106 | '/'
107 | "/billing"107 | '/billing'
108 | "/secrets"108 | '/secrets'
109 | "/team"109 | '/team'
110 | "/tools"110 | '/tools'
111 | "/billing/plans"111 | '/billing/plans'
112 | "/sources/$namespace"112 | '/sources/$namespace'
113 | "/sources/add/$pluginKey";113 | '/sources/add/$pluginKey'
114 id:114 id:
115 | "__root__"115 | '__root__'
116 | "/"116 | '/'
117 | "/billing"117 | '/billing'
118 | "/secrets"118 | '/secrets'
119 | "/team"119 | '/team'
120 | "/tools"120 | '/tools'
121 | "/billing_/plans"121 | '/billing_/plans'
122 | "/sources/$namespace"122 | '/sources/$namespace'
123 | "/sources/add/$pluginKey";123 | '/sources/add/$pluginKey'
124 fileRoutesById: FileRoutesById;124 fileRoutesById: FileRoutesById
125}125}
126export interface RootRouteChildren {126export interface RootRouteChildren {
127 IndexRoute: typeof IndexRoute;127 IndexRoute: typeof IndexRoute
128 BillingRoute: typeof BillingRoute;128 BillingRoute: typeof BillingRoute
129 SecretsRoute: typeof SecretsRoute;129 SecretsRoute: typeof SecretsRoute
130 TeamRoute: typeof TeamRoute;130 TeamRoute: typeof TeamRoute
131 ToolsRoute: typeof ToolsRoute;131 ToolsRoute: typeof ToolsRoute
132 BillingPlansRoute: typeof BillingPlansRoute;132 BillingPlansRoute: typeof BillingPlansRoute
133 SourcesNamespaceRoute: typeof SourcesNamespaceRoute;133 SourcesNamespaceRoute: typeof SourcesNamespaceRoute
134 SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute;134 SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute
135}135}
136 136
137declare module "@tanstack/react-router" {137declare module '@tanstack/react-router' {
138 interface FileRoutesByPath {138 interface FileRoutesByPath {
139 "/tools": {139 '/tools': {
140 id: "/tools";140 id: '/tools'
141 path: "/tools";141 path: '/tools'
142 fullPath: "/tools";142 fullPath: '/tools'
143 preLoaderRoute: typeof ToolsRouteImport;143 preLoaderRoute: typeof ToolsRouteImport
144 parentRoute: typeof rootRouteImport;144 parentRoute: typeof rootRouteImport
145 };145 }
146 "/team": {146 '/team': {
147 id: "/team";147 id: '/team'
148 path: "/team";148 path: '/team'
149 fullPath: "/team";149 fullPath: '/team'
150 preLoaderRoute: typeof TeamRouteImport;150 preLoaderRoute: typeof TeamRouteImport
151 parentRoute: typeof rootRouteImport;151 parentRoute: typeof rootRouteImport
152 };152 }
153 "/secrets": {153 '/secrets': {
154 id: "/secrets";154 id: '/secrets'
155 path: "/secrets";155 path: '/secrets'
156 fullPath: "/secrets";156 fullPath: '/secrets'
157 preLoaderRoute: typeof SecretsRouteImport;157 preLoaderRoute: typeof SecretsRouteImport
158 parentRoute: typeof rootRouteImport;158 parentRoute: typeof rootRouteImport
159 };159 }
160 "/billing": {160 '/billing': {
161 id: "/billing";161 id: '/billing'
162 path: "/billing";162 path: '/billing'
163 fullPath: "/billing";163 fullPath: '/billing'
164 preLoaderRoute: typeof BillingRouteImport;164 preLoaderRoute: typeof BillingRouteImport
165 parentRoute: typeof rootRouteImport;165 parentRoute: typeof rootRouteImport
166 };166 }
167 "/": {167 '/': {
168 id: "/";168 id: '/'
169 path: "/";169 path: '/'
170 fullPath: "/";170 fullPath: '/'
171 preLoaderRoute: typeof IndexRouteImport;171 preLoaderRoute: typeof IndexRouteImport
172 parentRoute: typeof rootRouteImport;172 parentRoute: typeof rootRouteImport
173 };173 }
174 "/sources/$namespace": {174 '/sources/$namespace': {
175 id: "/sources/$namespace";175 id: '/sources/$namespace'
176 path: "/sources/$namespace";176 path: '/sources/$namespace'
177 fullPath: "/sources/$namespace";177 fullPath: '/sources/$namespace'
178 preLoaderRoute: typeof SourcesNamespaceRouteImport;178 preLoaderRoute: typeof SourcesNamespaceRouteImport
179 parentRoute: typeof rootRouteImport;179 parentRoute: typeof rootRouteImport
180 };180 }
181 "/billing_/plans": {181 '/billing_/plans': {
182 id: "/billing_/plans";182 id: '/billing_/plans'
183 path: "/billing/plans";183 path: '/billing/plans'
184 fullPath: "/billing/plans";184 fullPath: '/billing/plans'
185 preLoaderRoute: typeof BillingPlansRouteImport;185 preLoaderRoute: typeof BillingPlansRouteImport
186 parentRoute: typeof rootRouteImport;186 parentRoute: typeof rootRouteImport
187 };187 }
188 "/sources/add/$pluginKey": {188 '/sources/add/$pluginKey': {
189 id: "/sources/add/$pluginKey";189 id: '/sources/add/$pluginKey'
190 path: "/sources/add/$pluginKey";190 path: '/sources/add/$pluginKey'
191 fullPath: "/sources/add/$pluginKey";191 fullPath: '/sources/add/$pluginKey'
192 preLoaderRoute: typeof SourcesAddPluginKeyRouteImport;192 preLoaderRoute: typeof SourcesAddPluginKeyRouteImport
193 parentRoute: typeof rootRouteImport;193 parentRoute: typeof rootRouteImport
194 };194 }
195 }195 }
196}196}
197 197
@@ -204,17 +204,17 @@ const rootRouteChildren: RootRouteChildren = { · 6 unmodified lines
204 BillingPlansRoute: BillingPlansRoute,204 BillingPlansRoute: BillingPlansRoute,
205 SourcesNamespaceRoute: SourcesNamespaceRoute,205 SourcesNamespaceRoute: SourcesNamespaceRoute,
206 SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute,206 SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute,
207};207}
208export const routeTree = rootRouteImport208export const routeTree = rootRouteImport
209 ._addFileChildren(rootRouteChildren)209 ._addFileChildren(rootRouteChildren)
210 ._addFileTypes<FileRouteTypes>();210 ._addFileTypes<FileRouteTypes>()
211 211
212import type { getRouter } from "./router.tsx";212import type { getRouter } from './router.tsx'
213import type { startInstance } from "./start.ts";213import type { startInstance } from './start.ts'
214declare module "@tanstack/react-start" {214declare module '@tanstack/react-start' {
215 interface Register {215 interface Register {
216 ssr: true;216 ssr: true
217 router: Awaited<ReturnType<typeof getRouter>>;217 router: Awaited<ReturnType<typeof getRouter>>
218 config: Awaited<ReturnType<typeof startInstance.getOptions>>;218 config: Awaited<ReturnType<typeof startInstance.getOptions>>
219 }219 }
220}220}
apps/cloud/vitest.unit.config.ts+8−0
@@ -0,0 +1,8 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 environment: "node",
6 include: ["src/api.test.ts", "src/api/protected.test.ts"],
7 },
8});
apps/local/src/server/mcp.ts+2−5
@@ -69,15 +69,12 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 68 unmodified lines
69 }69 }
70 return response;70 return response;
71 } catch (error) {71 } catch (error) {
72 console.error("[mcp] handleRequest error:", error instanceof Error ? error.stack : error);
72 if (!transport.sessionId) {73 if (!transport.sessionId) {
73 await transport.close().catch(() => undefined);74 await transport.close().catch(() => undefined);
74 await created?.close().catch(() => undefined);75 await created?.close().catch(() => undefined);
75 }76 }
76 return jsonError(77 return jsonError(500, -32603, "Internal server error");
77 500,
78 -32603,
79 error instanceof Error ? error.message : "Internal server error",
80 );
81 }78 }
82 },79 },
83 80
bun.lock+6−5
@@ -87,6 +87,7 @@ · 86 unmodified lines
87 "devDependencies": {87 "devDependencies": {
88 "@cloudflare/vitest-pool-workers": "^0.14.3",88 "@cloudflare/vitest-pool-workers": "^0.14.3",
89 "@cloudflare/workers-types": "^4.20250620.0",89 "@cloudflare/workers-types": "^4.20250620.0",
90 "@effect/platform-node": "catalog:",
90 "@effect/vitest": "^0.29.0",91 "@effect/vitest": "^0.29.0",
91 "@electric-sql/pglite": "^0.4.3",92 "@electric-sql/pglite": "^0.4.3",
92 "@electric-sql/pglite-socket": "^0.1.3",93 "@electric-sql/pglite-socket": "^0.1.3",
@@ -3867,7 +3868,7 @@ · 3774 unmodified lines
3867 3868
3868 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],3869 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
3869 3870
3870 "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],3871 "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=="],
3871 3872
3872 "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],3873 "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
3873 3874
@@ -3921,14 +3922,12 @@ · 47 unmodified lines
3921 3922
3922 "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],3923 "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
3923 3924
3925 "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
3926
3924 "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],3927 "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
3925 3928
3926 "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],3929 "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
3927 3930
3928 "@effect/platform-node/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=="],
3929
3930 "@effect/platform-node-shared/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=="],
3931
3932 "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],3931 "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
3933 3932
3934 "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],3933 "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -4229,6 +4228,8 @@ · 294 unmodified lines
4229 4228
4230 "miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],4229 "miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
4231 4230
4231 "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
4232
4232 "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],4233 "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
4233 4234
4234 "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],4235 "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
packages/plugins/google-discovery/src/api/group.ts+27−9
@@ -93,45 +93,63 @@ const OAuthCallbackParams = Schema.Struct({ · 92 unmodified lines
93 93
94const HtmlResponse = HttpApiSchema.Text({ contentType: "text/html" });94const HtmlResponse = HttpApiSchema.Text({ contentType: "text/html" });
95 95
96const ApiError = Schema.Struct({96export class GoogleDiscoveryApiError extends Schema.TaggedError<GoogleDiscoveryApiError>()(
97 message: Schema.String,97 "GoogleDiscoveryApiError",
98}).annotations(HttpApiSchema.annotations({ status: 400 }));98 {
99 message: Schema.String,
100 },
101 HttpApiSchema.annotations({ status: 400 }),
102) {}
103
104export class GoogleDiscoveryInternalError extends Schema.TaggedError<GoogleDiscoveryInternalError>()(
105 "GoogleDiscoveryInternalError",
106 {
107 message: Schema.String,
108 },
109 HttpApiSchema.annotations({ status: 500 }),
110) {}
99 111
100export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")112export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")
101 .add(113 .add(
102 HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe`114 HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe`
103 .setPayload(ProbePayload)115 .setPayload(ProbePayload)
104 .addSuccess(ProbeResponse)116 .addSuccess(ProbeResponse)
105 .addError(ApiError),117 .addError(GoogleDiscoveryApiError)
118 .addError(GoogleDiscoveryInternalError),
106 )119 )
107 .add(120 .add(
108 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources`121 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources`
109 .setPayload(AddSourcePayload)122 .setPayload(AddSourcePayload)
110 .addSuccess(AddSourceResponse)123 .addSuccess(AddSourceResponse)
111 .addError(ApiError),124 .addError(GoogleDiscoveryApiError)
125 .addError(GoogleDiscoveryInternalError),
112 )126 )
113 .add(127 .add(
114 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`128 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`
115 .setPayload(StartOAuthPayload)129 .setPayload(StartOAuthPayload)
116 .addSuccess(StartOAuthResponse)130 .addSuccess(StartOAuthResponse)
117 .addError(ApiError),131 .addError(GoogleDiscoveryApiError)
132 .addError(GoogleDiscoveryInternalError),
118 )133 )
119 .add(134 .add(
120 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete`135 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete`
121 .setPayload(CompleteOAuthPayload)136 .setPayload(CompleteOAuthPayload)
122 .addSuccess(CompleteOAuthResponse)137 .addSuccess(CompleteOAuthResponse)
123 .addError(ApiError),138 .addError(GoogleDiscoveryApiError)
139 .addError(GoogleDiscoveryInternalError),
124 )140 )
125 .add(141 .add(
126 HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback`142 HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback`
127 .setUrlParams(OAuthCallbackParams)143 .setUrlParams(OAuthCallbackParams)
128 .addSuccess(HtmlResponse)144 .addSuccess(HtmlResponse)
129 .addError(ApiError),145 .addError(GoogleDiscoveryApiError)
146 .addError(GoogleDiscoveryInternalError),
130 )147 )
131 .add(148 .add(
132 HttpApiEndpoint.get(149 HttpApiEndpoint.get(
133 "getSource",150 "getSource",
134 )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`151 )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`
135 .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema))152 .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema))
136 .addError(ApiError),153 .addError(GoogleDiscoveryApiError)
154 .addError(GoogleDiscoveryInternalError),
137 ) {}155 ) {}
packages/plugins/google-discovery/src/api/handlers.test.ts+66−0
@@ -0,0 +1,66 @@
1import { HttpApiBuilder, HttpServer } from "@effect/platform";
2import { describe, expect, it } from "vitest";
3import { Effect, Layer } from "effect";
4
5import { addGroup } from "@executor/api";
6import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
7import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";
8import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";
9import { GoogleDiscoveryGroup } from "./group";
10
11const unused = Effect.dieMessage("unused");
12
13const createFailingExtension = (): GoogleDiscoveryPluginExtension => ({
14 probeDiscovery: () => Effect.die(new Error("Not implemented")),
15 addSource: () => unused,
16 removeSource: () => unused,
17 startOAuth: () => unused,
18 completeOAuth: () => Effect.die(new Error("Not implemented")),
19 getSource: () => Effect.succeed(null),
20});
21
22const Api = addGroup(GoogleDiscoveryGroup);
23
24const fakeExecutor = {} as any;
25const fakeExecutionEngine = {} as any;
26
27const createHandler = () =>
28 HttpApiBuilder.toWebHandler(
29 HttpApiBuilder.api(Api).pipe(
30 Layer.provide(CoreHandlers),
31 Layer.provide(GoogleDiscoveryHandlers),
32 Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)),
33 Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)),
34 Layer.provide(Layer.succeed(GoogleDiscoveryExtensionService, createFailingExtension())),
35 Layer.provideMerge(HttpServer.layerContext),
36 Layer.provideMerge(HttpApiBuilder.Router.Live),
37 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
38 ),
39 );
40
41describe("GoogleDiscoveryHandlers", () => {
42 it("sanitizes unknown endpoint failures", async () => {
43 const web = createHandler();
44 try {
45 const response = await web.handler(
46 new Request("http://localhost/scopes/scope_1/google-discovery/probe", {
47 method: "POST",
48 headers: { "content-type": "application/json" },
49 body: JSON.stringify({
50 discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1",
51 }),
52 }),
53 );
54
55 expect(response.status).toBe(500);
56 const body = await response.json();
57 expect(body).toEqual({
58 _tag: "GoogleDiscoveryInternalError",
59 message: "Internal server error",
60 });
61 expect(JSON.stringify(body)).not.toContain("Not implemented");
62 } finally {
63 await web.dispose();
64 }
65 });
66});
packages/plugins/google-discovery/src/api/handlers.ts+51−10
@@ -1,5 +1,5 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Context, Effect } from "effect";2import { Cause, Context, Effect } from "effect";
3 3
4import { addGroup } from "@executor/api";4import { addGroup } from "@executor/api";
5import type {5import type {
@@ -7,7 +7,17 @@ import type { · 1 unmodified line
7 GoogleDiscoveryOAuthAuthResult,7 GoogleDiscoveryOAuthAuthResult,
8 GoogleDiscoveryPluginExtension,8 GoogleDiscoveryPluginExtension,
9} from "../sdk/plugin";9} from "../sdk/plugin";
10import { GoogleDiscoveryGroup } from "./group";10import {
11 GoogleDiscoveryInvocationError,
12 GoogleDiscoveryOAuthError,
13 GoogleDiscoveryParseError,
14 GoogleDiscoverySourceError,
15} from "../sdk/errors";
16import {
17 GoogleDiscoveryApiError,
18 GoogleDiscoveryGroup,
19 GoogleDiscoveryInternalError,
20} from "./group";
11 21
12export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<22export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<
13 GoogleDiscoveryExtensionService,23 GoogleDiscoveryExtensionService,
@@ -66,6 +76,37 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 52 unmodified lines
66</body></html>`;76</body></html>`;
67};77};
68 78
79const toPopupErrorMessage = (error: unknown): string => {
80 if (error instanceof GoogleDiscoveryOAuthError) {
81 return error.message;
82 }
83 return "Authentication failed";
84};
85
86const toGoogleDiscoveryApiError = (
87 error: unknown,
88): GoogleDiscoveryApiError | GoogleDiscoveryInternalError => {
89 if (error instanceof GoogleDiscoveryApiError || error instanceof GoogleDiscoveryInternalError) {
90 return error;
91 }
92 if (
93 error instanceof GoogleDiscoveryParseError ||
94 error instanceof GoogleDiscoverySourceError ||
95 error instanceof GoogleDiscoveryOAuthError ||
96 error instanceof GoogleDiscoveryInvocationError
97 ) {
98 return new GoogleDiscoveryApiError({ message: error.message });
99 }
100 return new GoogleDiscoveryInternalError({ message: "Internal server error" });
101};
102
103const sanitizeGoogleDiscoveryFailure = <A, R>(
104 effect: Effect.Effect<A, unknown, R>,
105): Effect.Effect<A, GoogleDiscoveryApiError | GoogleDiscoveryInternalError, R> =>
106 Effect.catchAllCause(effect, (cause) =>
107 Effect.fail(toGoogleDiscoveryApiError(Cause.squash(cause))),
108 );
109
69export const GoogleDiscoveryHandlers = HttpApiBuilder.group(110export const GoogleDiscoveryHandlers = HttpApiBuilder.group(
70 ExecutorApiWithGoogleDiscovery,111 ExecutorApiWithGoogleDiscovery,
71 "googleDiscovery",112 "googleDiscovery",
@@ -75,13 +116,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines
75 Effect.gen(function* () {116 Effect.gen(function* () {
76 const ext = yield* GoogleDiscoveryExtensionService;117 const ext = yield* GoogleDiscoveryExtensionService;
77 return yield* ext.probeDiscovery(payload.discoveryUrl);118 return yield* ext.probeDiscovery(payload.discoveryUrl);
78 }).pipe(Effect.orDie),119 }).pipe(sanitizeGoogleDiscoveryFailure),
79 )120 )
80 .handle("addSource", ({ payload }) =>121 .handle("addSource", ({ payload }) =>
81 Effect.gen(function* () {122 Effect.gen(function* () {
82 const ext = yield* GoogleDiscoveryExtensionService;123 const ext = yield* GoogleDiscoveryExtensionService;
83 return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput);124 return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput);
84 }).pipe(Effect.orDie),125 }).pipe(sanitizeGoogleDiscoveryFailure),
85 )126 )
86 .handle("startOAuth", ({ payload }) =>127 .handle("startOAuth", ({ payload }) =>
87 Effect.gen(function* () {128 Effect.gen(function* () {
@@ -94,7 +135,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 6 unmodified lines
94 redirectUrl: payload.redirectUrl,135 redirectUrl: payload.redirectUrl,
95 scopes: payload.scopes,136 scopes: payload.scopes,
96 });137 });
97 }).pipe(Effect.orDie),138 }).pipe(sanitizeGoogleDiscoveryFailure),
98 )139 )
99 .handle("completeOAuth", ({ payload }) =>140 .handle("completeOAuth", ({ payload }) =>
100 Effect.gen(function* () {141 Effect.gen(function* () {
@@ -104,13 +145,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines
104 code: payload.code,145 code: payload.code,
105 error: payload.error,146 error: payload.error,
106 });147 });
107 }).pipe(Effect.orDie),148 }).pipe(sanitizeGoogleDiscoveryFailure),
108 )149 )
109 .handle("getSource", ({ path }) =>150 .handle("getSource", ({ path }) =>
110 Effect.gen(function* () {151 Effect.gen(function* () {
111 const ext = yield* GoogleDiscoveryExtensionService;152 const ext = yield* GoogleDiscoveryExtensionService;
112 return yield* ext.getSource(path.namespace);153 return yield* ext.getSource(path.namespace);
113 }).pipe(Effect.orDie),154 }).pipe(sanitizeGoogleDiscoveryFailure),
114 )155 )
115 .handle("oauthCallback", ({ urlParams }) =>156 .handle("oauthCallback", ({ urlParams }) =>
116 Effect.gen(function* () {157 Effect.gen(function* () {
@@ -130,16 +171,16 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 13 unmodified lines
130 ...auth,171 ...auth,
131 }),172 }),
132 ),173 ),
133 Effect.catchAll((error) =>174 Effect.catchAllCause((cause) =>
134 Effect.succeed<OAuthPopupResult>({175 Effect.succeed<OAuthPopupResult>({
135 type: "executor:oauth-result",176 type: "executor:oauth-result",
136 ok: false,177 ok: false,
137 sessionId: null,178 sessionId: null,
138 error: error instanceof Error ? error.message : String(error),179 error: toPopupErrorMessage(Cause.squash(cause)),
139 }),180 }),
140 ),181 ),
141 );182 );
142 return yield* HttpServerResponse.html(popupDocument(result));183 return yield* HttpServerResponse.html(popupDocument(result));
143 }).pipe(Effect.orDie),184 }).pipe(sanitizeGoogleDiscoveryFailure),
144 ),185 ),
145);186);
packages/plugins/mcp/src/api/group.ts+33−12
@@ -152,9 +152,21 @@ const CompleteOAuthResponse = Schema.Struct({ · 151 unmodified lines
152// Error152// Error
153// ---------------------------------------------------------------------------153// ---------------------------------------------------------------------------
154 154
155const McpApiError = Schema.Struct({155export class McpApiError extends Schema.TaggedError<McpApiError>()(
156 message: Schema.String,156 "McpApiError",
157}).annotations(HttpApiSchema.annotations({ status: 400 }));157 {
158 message: Schema.String,
159 },
160 HttpApiSchema.annotations({ status: 400 }),
161) {}
162
163export class McpInternalError extends Schema.TaggedError<McpInternalError>()(
164 "McpInternalError",
165 {
166 message: Schema.String,
167 },
168 HttpApiSchema.annotations({ status: 500 }),
169) {}
158 170
159// ---------------------------------------------------------------------------171// ---------------------------------------------------------------------------
160// Group172// Group
@@ -165,52 +177,61 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 4 unmodified lines
165 HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe`177 HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe`
166 .setPayload(ProbeEndpointPayload)178 .setPayload(ProbeEndpointPayload)
167 .addSuccess(ProbeEndpointResponse)179 .addSuccess(ProbeEndpointResponse)
168 .addError(McpApiError),180 .addError(McpApiError)
181 .addError(McpInternalError),
169 )182 )
170 .add(183 .add(
171 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources`184 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources`
172 .setPayload(AddSourcePayload)185 .setPayload(AddSourcePayload)
173 .addSuccess(AddSourceResponse)186 .addSuccess(AddSourceResponse)
174 .addError(McpApiError),187 .addError(McpApiError)
188 .addError(McpInternalError),
175 )189 )
176 .add(190 .add(
177 HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove`191 HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove`
178 .setPayload(NamespacePayload)192 .setPayload(NamespacePayload)
179 .addSuccess(RemoveSourceResponse)193 .addSuccess(RemoveSourceResponse)
180 .addError(McpApiError),194 .addError(McpApiError)
195 .addError(McpInternalError),
181 )196 )
182 .add(197 .add(
183 HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh`198 HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh`
184 .setPayload(NamespacePayload)199 .setPayload(NamespacePayload)
185 .addSuccess(RefreshSourceResponse)200 .addSuccess(RefreshSourceResponse)
186 .addError(McpApiError),201 .addError(McpApiError)
202 .addError(McpInternalError),
187 )203 )
188 .add(204 .add(
189 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start`205 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start`
190 .setPayload(StartOAuthPayload)206 .setPayload(StartOAuthPayload)
191 .addSuccess(StartOAuthResponse)207 .addSuccess(StartOAuthResponse)
192 .addError(McpApiError),208 .addError(McpApiError)
209 .addError(McpInternalError),
193 )210 )
194 .add(211 .add(
195 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete`212 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete`
196 .setPayload(CompleteOAuthPayload)213 .setPayload(CompleteOAuthPayload)
197 .addSuccess(CompleteOAuthResponse)214 .addSuccess(CompleteOAuthResponse)
198 .addError(McpApiError),215 .addError(McpApiError)
216 .addError(McpInternalError),
199 )217 )
200 .add(218 .add(
201 HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback`219 HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback`
202 .setUrlParams(OAuthCallbackParams)220 .setUrlParams(OAuthCallbackParams)
203 .addSuccess(HtmlResponse)221 .addSuccess(HtmlResponse)
204 .addError(McpApiError),222 .addError(McpApiError)
223 .addError(McpInternalError),
205 )224 )
206 .add(225 .add(
207 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`226 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
208 .addSuccess(Schema.NullOr(McpStoredSourceSchema))227 .addSuccess(Schema.NullOr(McpStoredSourceSchema))
209 .addError(McpApiError),228 .addError(McpApiError)
229 .addError(McpInternalError),
210 )230 )
211 .add(231 .add(
212 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`232 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
213 .setPayload(UpdateSourcePayload)233 .setPayload(UpdateSourcePayload)
214 .addSuccess(UpdateSourceResponse)234 .addSuccess(UpdateSourceResponse)
215 .addError(McpApiError),235 .addError(McpApiError)
236 .addError(McpInternalError),
216 ) {}237 ) {}
packages/plugins/mcp/src/api/handlers.test.ts+66−0
@@ -0,0 +1,66 @@
1import { HttpApiBuilder, HttpServer } from "@effect/platform";
2import { describe, expect, it } from "vitest";
3import { Effect, Layer } from "effect";
4
5import { addGroup } from "@executor/api";
6import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
7import type { McpPluginExtension } from "../sdk/plugin";
8import { McpExtensionService, McpHandlers } from "./handlers";
9import { McpGroup } from "./group";
10
11const unused = Effect.dieMessage("unused");
12
13const createFailingExtension = (): McpPluginExtension => ({
14 probeEndpoint: () => Effect.die(new Error("Not implemented")),
15 addSource: () => unused,
16 removeSource: () => unused,
17 refreshSource: () => unused,
18 startOAuth: () => unused,
19 completeOAuth: () => Effect.die(new Error("Not implemented")),
20 getSource: () => Effect.succeed(null),
21 updateSource: () => unused,
22});
23
24const Api = addGroup(McpGroup);
25
26const fakeExecutor = {} as any;
27const fakeExecutionEngine = {} as any;
28
29const createHandler = () =>
30 HttpApiBuilder.toWebHandler(
31 HttpApiBuilder.api(Api).pipe(
32 Layer.provide(CoreHandlers),
33 Layer.provide(McpHandlers),
34 Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)),
35 Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)),
36 Layer.provide(Layer.succeed(McpExtensionService, createFailingExtension())),
37 Layer.provideMerge(HttpServer.layerContext),
38 Layer.provideMerge(HttpApiBuilder.Router.Live),
39 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
40 ),
41 );
42
43describe("McpHandlers", () => {
44 it("sanitizes unknown endpoint failures", async () => {
45 const web = createHandler();
46 try {
47 const response = await web.handler(
48 new Request("http://localhost/scopes/scope_1/mcp/probe", {
49 method: "POST",
50 headers: { "content-type": "application/json" },
51 body: JSON.stringify({ endpoint: "https://example.com/mcp" }),
52 }),
53 );
54
55 expect(response.status).toBe(500);
56 const body = await response.json();
57 expect(body).toEqual({
58 _tag: "McpInternalError",
59 message: "Internal server error",
60 });
61 expect(JSON.stringify(body)).not.toContain("Not implemented");
62 } finally {
63 await web.dispose();
64 }
65 });
66});
packages/plugins/mcp/src/api/handlers.ts+46−13
@@ -1,9 +1,15 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Context, Effect } from "effect";2import { Cause, Context, Effect } from "effect";
3 3
4import { addGroup } from "@executor/api";4import { addGroup } from "@executor/api";
5import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";5import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";
6import { McpGroup } from "./group";6import {
7 McpConnectionError,
8 McpInvocationError,
9 McpOAuthError,
10 McpToolDiscoveryError,
11} from "../sdk/errors";
12import { McpApiError, McpGroup, McpInternalError } from "./group";
7 13
8// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
9// Service tag15// Service tag
@@ -79,6 +85,33 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 69 unmodified lines
79</body></html>`;85</body></html>`;
80};86};
81 87
88const toPopupErrorMessage = (error: unknown): string => {
89 if (error instanceof McpOAuthError) {
90 return error.message;
91 }
92 return "Authentication failed";
93};
94
95const toMcpApiError = (error: unknown): McpApiError | McpInternalError => {
96 if (error instanceof McpApiError || error instanceof McpInternalError) {
97 return error;
98 }
99 if (
100 error instanceof McpConnectionError ||
101 error instanceof McpToolDiscoveryError ||
102 error instanceof McpInvocationError ||
103 error instanceof McpOAuthError
104 ) {
105 return new McpApiError({ message: error.message });
106 }
107 return new McpInternalError({ message: "Internal server error" });
108};
109
110const sanitizeMcpFailure = <A, R>(
111 effect: Effect.Effect<A, unknown, R>,
112): Effect.Effect<A, McpApiError | McpInternalError, R> =>
113 Effect.catchAllCause(effect, (cause) => Effect.fail(toMcpApiError(Cause.squash(cause))));
114
82// ---------------------------------------------------------------------------115// ---------------------------------------------------------------------------
83// Convert API payload → McpSourceConfig116// Convert API payload → McpSourceConfig
84// ---------------------------------------------------------------------------117// ---------------------------------------------------------------------------
@@ -150,7 +183,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 65 unmodified lines
150 Effect.gen(function* () {183 Effect.gen(function* () {
151 const ext = yield* McpExtensionService;184 const ext = yield* McpExtensionService;
152 return yield* ext.probeEndpoint(payload.endpoint);185 return yield* ext.probeEndpoint(payload.endpoint);
153 }).pipe(Effect.orDie),186 }).pipe(sanitizeMcpFailure),
154 )187 )
155 .handle("addSource", ({ payload }) =>188 .handle("addSource", ({ payload }) =>
156 Effect.gen(function* () {189 Effect.gen(function* () {
@@ -158,20 +191,20 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 1 unmodified line
158 return yield* ext.addSource(191 return yield* ext.addSource(
159 toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]),192 toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]),
160 );193 );
161 }).pipe(Effect.orDie),194 }).pipe(sanitizeMcpFailure),
162 )195 )
163 .handle("removeSource", ({ payload }) =>196 .handle("removeSource", ({ payload }) =>
164 Effect.gen(function* () {197 Effect.gen(function* () {
165 const ext = yield* McpExtensionService;198 const ext = yield* McpExtensionService;
166 yield* ext.removeSource(payload.namespace);199 yield* ext.removeSource(payload.namespace);
167 return { removed: true };200 return { removed: true };
168 }).pipe(Effect.orDie),201 }).pipe(sanitizeMcpFailure),
169 )202 )
170 .handle("refreshSource", ({ payload }) =>203 .handle("refreshSource", ({ payload }) =>
171 Effect.gen(function* () {204 Effect.gen(function* () {
172 const ext = yield* McpExtensionService;205 const ext = yield* McpExtensionService;
173 return yield* ext.refreshSource(payload.namespace);206 return yield* ext.refreshSource(payload.namespace);
174 }).pipe(Effect.orDie),207 }).pipe(sanitizeMcpFailure),
175 )208 )
176 .handle("startOAuth", ({ payload }) =>209 .handle("startOAuth", ({ payload }) =>
177 Effect.gen(function* () {210 Effect.gen(function* () {
@@ -181,7 +214,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines
181 redirectUrl: payload.redirectUrl,214 redirectUrl: payload.redirectUrl,
182 queryParams: payload.queryParams,215 queryParams: payload.queryParams,
183 });216 });
184 }).pipe(Effect.orDie),217 }).pipe(sanitizeMcpFailure),
185 )218 )
186 .handle("completeOAuth", ({ payload }) =>219 .handle("completeOAuth", ({ payload }) =>
187 Effect.gen(function* () {220 Effect.gen(function* () {
@@ -191,13 +224,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines
191 code: payload.code,224 code: payload.code,
192 error: payload.error,225 error: payload.error,
193 });226 });
194 }).pipe(Effect.orDie),227 }).pipe(sanitizeMcpFailure),
195 )228 )
196 .handle("getSource", ({ path }) =>229 .handle("getSource", ({ path }) =>
197 Effect.gen(function* () {230 Effect.gen(function* () {
198 const ext = yield* McpExtensionService;231 const ext = yield* McpExtensionService;
199 return yield* ext.getSource(path.namespace);232 return yield* ext.getSource(path.namespace);
200 }).pipe(Effect.orDie),233 }).pipe(sanitizeMcpFailure),
201 )234 )
202 .handle("updateSource", ({ path, payload }) =>235 .handle("updateSource", ({ path, payload }) =>
203 Effect.gen(function* () {236 Effect.gen(function* () {
@@ -209,7 +242,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 5 unmodified lines
209 auth: payload.auth as McpUpdateSourceInput["auth"],242 auth: payload.auth as McpUpdateSourceInput["auth"],
210 });243 });
211 return { updated: true };244 return { updated: true };
212 }).pipe(Effect.orDie),245 }).pipe(sanitizeMcpFailure),
213 )246 )
214 .handle("oauthCallback", ({ urlParams }) =>247 .handle("oauthCallback", ({ urlParams }) =>
215 Effect.gen(function* () {248 Effect.gen(function* () {
@@ -233,16 +266,16 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 17 unmodified lines
233 scope: c.scope,266 scope: c.scope,
234 }),267 }),
235 ),268 ),
236 Effect.catchAll((error) =>269 Effect.catchAllCause((cause) =>
237 Effect.succeed<OAuthPopupResult>({270 Effect.succeed<OAuthPopupResult>({
238 type: "executor:oauth-result",271 type: "executor:oauth-result",
239 ok: false,272 ok: false,
240 sessionId: null,273 sessionId: null,
241 error: error instanceof Error ? error.message : String(error),274 error: toPopupErrorMessage(Cause.squash(cause)),
242 }),275 }),
243 ),276 ),
244 );277 );
245 return yield* HttpServerResponse.html(popupDocument(result));278 return yield* HttpServerResponse.html(popupDocument(result));
246 }).pipe(Effect.orDie),279 }).pipe(sanitizeMcpFailure),
247 ),280 ),
248);281);