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
1111 cancel-in-progress: true
1212
1313 jobs:
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−
2814 lint:
2915 name: Lint
3016 runs-on: ubuntu-latest
.github/workflows/format.yml+0−28
@@ -1,28 +0,0 @@
1−name: Format
2−
3−on:
4− pull_request:
5−
6−permissions:
7− contents: write
8−
9−jobs:
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 @@
11 {
22 "$schema": "./node_modules/oxfmt/configuration_schema.json",
3− "ignorePatterns": []
3+ "ignorePatterns": ["**/routeTree.gen.ts"]
44 }
.oxlintrc.jsonc+1−1
@@ -16,5 +16,5 @@ · 15 unmodified lines
1616 },
1717 ],
1818 },
19− "ignorePatterns": [".astro/"],
19+ "ignorePatterns": [".astro/", "**/routeTree.gen.ts"],
2020 }
apps/cloud/package.json+1−0
@@ -61,6 +61,7 @@ · 60 unmodified lines
6161 "devDependencies": {
6262 "@cloudflare/vitest-pool-workers": "^0.14.3",
6363 "@cloudflare/workers-types": "^4.20250620.0",
64+ "@effect/platform-node": "catalog:",
6465 "@effect/vitest": "^0.29.0",
6566 "@electric-sql/pglite": "^0.4.3",
6667 "@electric-sql/pglite-socket": "^0.1.3",
apps/cloud/src/api.test.ts+235−0
@@ -0,0 +1,235 @@
1+import {
2+ HttpApi,
3+ HttpApiBuilder,
4+ HttpApiClient,
5+ HttpApiEndpoint,
6+ HttpApiGroup,
7+ HttpApp,
8+ HttpClient,
9+ HttpServer,
10+ HttpServerResponse,
11+} from "@effect/platform";
12+import { NodeHttpServer } from "@effect/platform-node";
13+import { expect, layer } from "@effect/vitest";
14+import { Effect, Layer, Schema } from "effect";
15+
16+import {
17+ ApiRequestHandler,
18+ AutumnRequestHandlerService,
19+ NonProtectedRequestHandlerService,
20+ ProtectedRequestHandlerService,
21+ TeamRequestHandlerService,
22+} from "./api/router";
23+
24+const SourceResponse = Schema.Struct({ source: Schema.String });
25+
26+const TeamGroup = HttpApiGroup.make("team").add(
27+ HttpApiEndpoint.get("ping", "/team/ping").addSuccess(SourceResponse),
28+);
29+const TeamApi = HttpApi.make("teamApi").add(TeamGroup);
30+const TeamHandlers = HttpApiBuilder.group(TeamApi, "team", (handlers) =>
31+ handlers.handle("ping", () => Effect.succeed({ source: "team" })),
32+);
33+
34+const AuthGroup = HttpApiGroup.make("auth").add(
35+ HttpApiEndpoint.get("me", "/auth/me").addSuccess(SourceResponse),
36+);
37+const AuthApi = HttpApi.make("authApi").add(AuthGroup);
38+const AuthHandlers = HttpApiBuilder.group(AuthApi, "auth", (handlers) =>
39+ handlers.handle("me", () => Effect.succeed({ source: "auth" })),
40+);
41+
42+const 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+ );
49+const ProtectedApi = HttpApi.make("protectedApi").add(ProtectedGroup);
50+const ProtectedHandlers = HttpApiBuilder.group(ProtectedApi, "protected", (handlers) =>
51+ handlers
52+ .handle("scope", () => Effect.succeed({ source: "protected" }))
53+ .handle("resume", () => Effect.succeed({ source: "protected" })),
54+);
55+
56+const 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+
70+const 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+
84+const 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+
98+type ProtectedMode = "ok" | "none" | "error" | "bad-status";
99+
100+const testState: {
101+ mode: ProtectedMode;
102+} = {
103+ mode: "ok",
104+};
105+
106+const resetState = () => {
107+ testState.mode = "ok";
108+};
109+
110+const 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+
126+const 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+
135+const requestHandler = Effect.runSync(Effect.provide(ApiRequestHandler, TestRequestHandlersLive));
136+
137+const 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+
147+const TestServerLayer = HttpServer.serve(HttpApp.fromWebHandler(requestHandler)).pipe(
148+ Layer.provideMerge(NodeHttpServer.layerTest),
149+);
150+
151+const getClient = () => HttpApiClient.make(TestApi);
152+
153+layer(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−
5−import { env } from "cloudflare:workers";
6−import * as Sentry from "@sentry/cloudflare";
7−import {
8− HttpApiBuilder,
9− HttpApiSwagger,
10− HttpMiddleware,
11− HttpRouter,
12− HttpServer,
13−} from "@effect/platform";
141 import { Effect, Layer } from "effect";
15−import { Autumn } from "autumn-js";
16−import { autumnHandler } from "autumn-js/backend";
17−
18−import { CoreExecutorApi } from "@executor/api";
19−import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";
20−import { createExecutionEngine } from "@executor/execution";
21−import { makeDynamicWorkerExecutor, type CodeExecutor } from "@executor/runtime-dynamic-worker";
22−import {
23− OpenApiGroup,
24− OpenApiExtensionService,
25− OpenApiHandlers,
26−} from "@executor/plugin-openapi/api";
27−import { McpGroup, McpExtensionService, McpHandlers } from "@executor/plugin-mcp/api";
28−import {
29− GoogleDiscoveryGroup,
30− GoogleDiscoveryExtensionService,
31− GoogleDiscoveryHandlers,
32−} from "@executor/plugin-google-discovery/api";
33−import {
34− GraphqlGroup,
35− GraphqlExtensionService,
36− GraphqlHandlers,
37−} from "@executor/plugin-graphql/api";
38−
39−import { OrgAuth } from "./auth/middleware";
40−import { OrgAuthLive, SessionAuthLive } from "./auth/middleware-live";
41−import { UserStoreService } from "./auth/context";
2+import { AutumnApiApp } from "./api/autumn";
3+import { NonProtectedApiApp, TeamApiApp } from "./api/layers";
4+import { ProtectedApiApp } from "./api/protected";
425 import {
43− CloudAuthPublicHandlers,
44− CloudSessionAuthHandlers,
45− NonProtectedApi,
46−} from "./auth/handlers";
47−import { WorkOSAuth } from "./auth/workos";
48−import { DbService } from "./services/db";
49−import { createOrgExecutor } from "./services/executor";
50−import { TeamOrgApi } from "./team/compose";
51−import { TeamHandlers } from "./team/handlers";
52−import { TelemetryLive } from "./services/telemetry";
53−import { server } from "./env";
54−
55−// ---------------------------------------------------------------------------
56−// API definitions
57−// ---------------------------------------------------------------------------
58−
59−/** Protected (org-required) API — all the executor groups + OrgAuth middleware */
60−const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
61− .add(McpGroup)
62− .add(GoogleDiscoveryGroup)
63− .add(GraphqlGroup)
64− .middleware(OrgAuth);
65−
66−// ---------------------------------------------------------------------------
67−// Layers
68−// ---------------------------------------------------------------------------
69−
70−const DbLive = DbService.Live;
71−const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
72−
73−const SharedServices = Layer.mergeAll(
74− DbLive,
75− UserStoreLive,
76− WorkOSAuth.Default,
77− HttpServer.layerContext,
78−).pipe(Layer.provideMerge(TelemetryLive));
79−const 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−
92−const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe(
93− Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
94− Layer.provideMerge(SessionAuthLive),
6+ ApiRequestHandler,
7+ AutumnRequestHandlerService,
8+ NonProtectedRequestHandlerService,
9+ ProtectedRequestHandlerService,
10+ TeamRequestHandlerService,
11+} from "./api/router";
12+
13+const ApiRequestHandlersLive = Layer.mergeAll(
14+ Layer.succeed(TeamRequestHandlerService, { app: TeamApiApp }),
15+ Layer.succeed(NonProtectedRequestHandlerService, { app: NonProtectedApiApp }),
16+ Layer.succeed(AutumnRequestHandlerService, { app: AutumnApiApp }),
17+ Layer.succeed(ProtectedRequestHandlerService, { app: ProtectedApiApp }),
9518 );
9619
97−const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe(
98− Layer.provide(TeamHandlers),
99− Layer.provideMerge(OrgAuthLive),
20+export const handleApiRequest = Effect.runSync(
21+ Effect.provide(ApiRequestHandler, ApiRequestHandlersLive),
10022 );
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−
117−const RouterConfig = HttpRouter.setRouterConfig({ maxParamLength: 1000 });
118−
119−const createNonProtectedHandler = () =>
120− HttpApiBuilder.toWebHandler(
121− NonProtectedApiLive.pipe(Layer.provideMerge(SharedServices), Layer.provideMerge(RouterConfig)),
122− { middleware: HttpMiddleware.logger },
123− );
124−
125−const 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−
135−const 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−
174−const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/");
175−const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/");
176−const isTeamPath = (pathname: string): boolean => pathname.startsWith("/team/");
177−const 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−
184−let _autumn: Autumn | null = null;
185−const 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− */
197−const 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−
210−const 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−
269−const SENTRY_HOST = "o4511196985556992.ingest.us.sentry.io";
270−const SENTRY_PROJECT_IDS = new Set(["4511197001416704", "4511197010067456"]);
271−
272−const 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−
293−export 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 @@
1+import { Effect } from "effect";
2+import { HttpServerRequest, HttpServerResponse } from "@effect/platform";
3+import { Autumn } from "autumn-js";
4+import { autumnHandler } from "autumn-js/backend";
5+
6+import { WorkOSAuth } from "../auth/workos";
7+import { server } from "../env";
8+import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
9+import { SharedServices } from "./layers";
10+
11+let cachedAutumn: Autumn | null = null;
12+
13+const getAutumn = () => {
14+ if (!cachedAutumn && server.AUTUMN_SECRET_KEY) {
15+ cachedAutumn = new Autumn({ secretKey: server.AUTUMN_SECRET_KEY });
16+ }
17+ return cachedAutumn;
18+};
19+
20+export 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+
35+const 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+
115+export const AutumnApiApp = handleAutumnRequestEffect;
apps/cloud/src/api/error-response.ts+33−0
@@ -0,0 +1,33 @@
1+import { Data } from "effect";
2+import { HttpServerResponse } from "@effect/platform";
3+
4+export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{
5+ readonly status: number;
6+ readonly code: string;
7+ readonly message: string;
8+}> {}
9+
10+const 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+
19+export const isServerError = (error: unknown): boolean => toHttpResponseError(error).status >= 500;
20+
21+export 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+
26+export 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 @@
1+import type { ExecutionEngine } from "@executor/execution";
2+
3+export 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 @@
1+import { HttpApiBuilder, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform";
2+import { Effect, Layer } from "effect";
3+
4+import { CoreExecutorApi } from "@executor/api";
5+import { CoreHandlers } from "@executor/api/server";
6+import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";
7+import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";
8+import {
9+ GoogleDiscoveryGroup,
10+ GoogleDiscoveryHandlers,
11+} from "@executor/plugin-google-discovery/api";
12+import { GraphqlGroup, GraphqlHandlers } from "@executor/plugin-graphql/api";
13+
14+import { OrgAuth } from "../auth/middleware";
15+import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";
16+import { UserStoreService } from "../auth/context";
17+import {
18+ CloudAuthPublicHandlers,
19+ CloudSessionAuthHandlers,
20+ NonProtectedApi,
21+} from "../auth/handlers";
22+import { WorkOSAuth } from "../auth/workos";
23+import { DbService } from "../services/db";
24+import { TeamOrgApi } from "../team/compose";
25+import { TeamHandlers } from "../team/handlers";
26+
27+const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
28+ .add(McpGroup)
29+ .add(GoogleDiscoveryGroup)
30+ .add(GraphqlGroup)
31+ .middleware(OrgAuth);
32+
33+const DbLive = DbService.Live;
34+const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
35+
36+export const SharedServices = Layer.mergeAll(
37+ DbLive,
38+ UserStoreLive,
39+ WorkOSAuth.Default,
40+ HttpServer.layerContext,
41+);
42+
43+export const RouterConfig = HttpRouter.setRouterConfig({ maxParamLength: 1000 });
44+
45+export 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+
58+const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe(
59+ Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
60+ Layer.provideMerge(SessionAuthLive),
61+);
62+
63+const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe(
64+ Layer.provide(TeamHandlers),
65+ Layer.provideMerge(OrgAuthLive),
66+);
67+
68+const 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+
75+const 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+
82+export const NonProtectedApiApp = Effect.flatMap(
83+ HttpApiBuilder.httpApp.pipe(Effect.provide(NonProtectedRequestLayer)),
84+ HttpMiddleware.logger,
85+).pipe(Effect.provide(SharedServices));
86+
87+export 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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import type { ExecutionEngine } from "@executor/execution";
4+import { withExecutionUsageTracking } from "./execution-usage";
5+
6+const 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+
20+describe("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 @@
1+import { env } from "cloudflare:workers";
2+import { HttpApiBuilder, HttpApiSwagger, HttpServerRequest } from "@effect/platform";
3+import { Effect, Layer } from "effect";
4+
5+import { ExecutorService, ExecutionEngineService } from "@executor/api/server";
6+import { createExecutionEngine } from "@executor/execution";
7+import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker";
8+import { OpenApiExtensionService } from "@executor/plugin-openapi/api";
9+import { McpExtensionService } from "@executor/plugin-mcp/api";
10+import { GoogleDiscoveryExtensionService } from "@executor/plugin-google-discovery/api";
11+import { GraphqlExtensionService } from "@executor/plugin-graphql/api";
12+
13+import { UserStoreService } from "../auth/context";
14+import { WorkOSAuth } from "../auth/workos";
15+import { server } from "../env";
16+import { createOrgExecutor } from "../services/executor";
17+import { trackExecutionUsage } from "./autumn";
18+import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
19+import { withExecutionUsageTracking } from "./execution-usage";
20+import { ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers";
21+
22+const 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+
41+const 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+
78+const 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+
103+export const ProtectedApiApp = handleProtectedRequestEffect;
apps/cloud/src/api/router.ts+39−0
@@ -0,0 +1,39 @@
1+import { HttpApp, HttpRouter } from "@effect/platform";
2+import { Context, Effect } from "effect";
3+
4+type RequestAppService = {
5+ readonly app: HttpApp.Default;
6+};
7+
8+export class TeamRequestHandlerService extends Context.Tag(
9+ "@executor/cloud/TeamRequestHandlerService",
10+)<TeamRequestHandlerService, RequestAppService>() {}
11+
12+export class NonProtectedRequestHandlerService extends Context.Tag(
13+ "@executor/cloud/NonProtectedRequestHandlerService",
14+)<NonProtectedRequestHandlerService, RequestAppService>() {}
15+
16+export class AutumnRequestHandlerService extends Context.Tag(
17+ "@executor/cloud/AutumnRequestHandlerService",
18+)<AutumnRequestHandlerService, RequestAppService>() {}
19+
20+export class ProtectedRequestHandlerService extends Context.Tag(
21+ "@executor/cloud/ProtectedRequestHandlerService",
22+)<ProtectedRequestHandlerService, RequestAppService>() {}
23+
24+export 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+
39+export 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
8787
8888 setCookie("wos-session", sealedSession, COOKIE_OPTIONS);
8989 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+ ),
9196 ),
9297 );
9398
apps/cloud/src/mcp-session.ts+1−1
@@ -143,7 +143,7 @@ export class McpSessionDO extends DurableObject { · 142 unmodified lines
143143 return await this.transport.handleRequest(request);
144144 } catch (err) {
145145 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");
147147 }
148148 }
149149
apps/cloud/src/mcp.ts+1−1
@@ -128,7 +128,7 @@ const handleMcpRequest_POST = async (request: Request, token: VerifiedToken): Pr · 127 unmodified lines
128128 return await stub.handleRequest(request);
129129 } catch (err) {
130130 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");
132132 }
133133 };
134134
apps/cloud/src/routeTree.gen.ts+159−159
@@ -8,190 +8,190 @@ · 7 unmodified lines
88 // You should NOT make any changes in this file as it will be overwritten.
99 // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010
11−import { Route as rootRouteImport } from "./routes/__root";
12−import { Route as ToolsRouteImport } from "./routes/tools";
13−import { Route as TeamRouteImport } from "./routes/team";
14−import { Route as SecretsRouteImport } from "./routes/secrets";
15−import { Route as BillingRouteImport } from "./routes/billing";
16−import { Route as IndexRouteImport } from "./routes/index";
17−import { Route as SourcesNamespaceRouteImport } from "./routes/sources.$namespace";
18−import { Route as BillingPlansRouteImport } from "./routes/billing_.plans";
19−import { Route as SourcesAddPluginKeyRouteImport } from "./routes/sources.add.$pluginKey";
11+import { Route as rootRouteImport } from './routes/__root'
12+import { Route as ToolsRouteImport } from './routes/tools'
13+import { Route as TeamRouteImport } from './routes/team'
14+import { Route as SecretsRouteImport } from './routes/secrets'
15+import { Route as BillingRouteImport } from './routes/billing'
16+import { Route as IndexRouteImport } from './routes/index'
17+import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'
18+import { Route as BillingPlansRouteImport } from './routes/billing_.plans'
19+import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey'
2020
2121 const ToolsRoute = ToolsRouteImport.update({
22− id: "/tools",
23− path: "/tools",
22+ id: '/tools',
23+ path: '/tools',
2424 getParentRoute: () => rootRouteImport,
25−} as any);
25+} as any)
2626 const TeamRoute = TeamRouteImport.update({
27− id: "/team",
28− path: "/team",
27+ id: '/team',
28+ path: '/team',
2929 getParentRoute: () => rootRouteImport,
30−} as any);
30+} as any)
3131 const SecretsRoute = SecretsRouteImport.update({
32− id: "/secrets",
33− path: "/secrets",
32+ id: '/secrets',
33+ path: '/secrets',
3434 getParentRoute: () => rootRouteImport,
35−} as any);
35+} as any)
3636 const BillingRoute = BillingRouteImport.update({
37− id: "/billing",
38− path: "/billing",
37+ id: '/billing',
38+ path: '/billing',
3939 getParentRoute: () => rootRouteImport,
40−} as any);
40+} as any)
4141 const IndexRoute = IndexRouteImport.update({
42− id: "/",
43− path: "/",
42+ id: '/',
43+ path: '/',
4444 getParentRoute: () => rootRouteImport,
45−} as any);
45+} as any)
4646 const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({
47− id: "/sources/$namespace",
48− path: "/sources/$namespace",
47+ id: '/sources/$namespace',
48+ path: '/sources/$namespace',
4949 getParentRoute: () => rootRouteImport,
50−} as any);
50+} as any)
5151 const BillingPlansRoute = BillingPlansRouteImport.update({
52− id: "/billing_/plans",
53− path: "/billing/plans",
52+ id: '/billing_/plans',
53+ path: '/billing/plans',
5454 getParentRoute: () => rootRouteImport,
55−} as any);
55+} as any)
5656 const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({
57− id: "/sources/add/$pluginKey",
58− path: "/sources/add/$pluginKey",
57+ id: '/sources/add/$pluginKey',
58+ path: '/sources/add/$pluginKey',
5959 getParentRoute: () => rootRouteImport,
60−} as any);
60+} as any)
6161
6262 export interface FileRoutesByFullPath {
63− "/": typeof IndexRoute;
64− "/billing": typeof BillingRoute;
65− "/secrets": typeof SecretsRoute;
66− "/team": typeof TeamRoute;
67− "/tools": typeof ToolsRoute;
68− "/billing/plans": typeof BillingPlansRoute;
69− "/sources/$namespace": typeof SourcesNamespaceRoute;
70− "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute;
63+ '/': typeof IndexRoute
64+ '/billing': typeof BillingRoute
65+ '/secrets': typeof SecretsRoute
66+ '/team': typeof TeamRoute
67+ '/tools': typeof ToolsRoute
68+ '/billing/plans': typeof BillingPlansRoute
69+ '/sources/$namespace': typeof SourcesNamespaceRoute
70+ '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
7171 }
7272 export interface FileRoutesByTo {
73− "/": typeof IndexRoute;
74− "/billing": typeof BillingRoute;
75− "/secrets": typeof SecretsRoute;
76− "/team": typeof TeamRoute;
77− "/tools": typeof ToolsRoute;
78− "/billing/plans": typeof BillingPlansRoute;
79− "/sources/$namespace": typeof SourcesNamespaceRoute;
80− "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute;
73+ '/': typeof IndexRoute
74+ '/billing': typeof BillingRoute
75+ '/secrets': typeof SecretsRoute
76+ '/team': typeof TeamRoute
77+ '/tools': typeof ToolsRoute
78+ '/billing/plans': typeof BillingPlansRoute
79+ '/sources/$namespace': typeof SourcesNamespaceRoute
80+ '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
8181 }
8282 export interface FileRoutesById {
83− __root__: typeof rootRouteImport;
84− "/": typeof IndexRoute;
85− "/billing": typeof BillingRoute;
86− "/secrets": typeof SecretsRoute;
87− "/team": typeof TeamRoute;
88− "/tools": typeof ToolsRoute;
89− "/billing_/plans": typeof BillingPlansRoute;
90− "/sources/$namespace": typeof SourcesNamespaceRoute;
91− "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute;
83+ __root__: typeof rootRouteImport
84+ '/': typeof IndexRoute
85+ '/billing': typeof BillingRoute
86+ '/secrets': typeof SecretsRoute
87+ '/team': typeof TeamRoute
88+ '/tools': typeof ToolsRoute
89+ '/billing_/plans': typeof BillingPlansRoute
90+ '/sources/$namespace': typeof SourcesNamespaceRoute
91+ '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
9292 }
9393 export interface FileRouteTypes {
94− fileRoutesByFullPath: FileRoutesByFullPath;
94+ fileRoutesByFullPath: FileRoutesByFullPath
9595 fullPaths:
96− | "/"
97− | "/billing"
98− | "/secrets"
99− | "/team"
100− | "/tools"
101− | "/billing/plans"
102− | "/sources/$namespace"
103− | "/sources/add/$pluginKey";
104− fileRoutesByTo: FileRoutesByTo;
96+ | '/'
97+ | '/billing'
98+ | '/secrets'
99+ | '/team'
100+ | '/tools'
101+ | '/billing/plans'
102+ | '/sources/$namespace'
103+ | '/sources/add/$pluginKey'
104+ fileRoutesByTo: FileRoutesByTo
105105 to:
106− | "/"
107− | "/billing"
108− | "/secrets"
109− | "/team"
110− | "/tools"
111− | "/billing/plans"
112− | "/sources/$namespace"
113− | "/sources/add/$pluginKey";
106+ | '/'
107+ | '/billing'
108+ | '/secrets'
109+ | '/team'
110+ | '/tools'
111+ | '/billing/plans'
112+ | '/sources/$namespace'
113+ | '/sources/add/$pluginKey'
114114 id:
115− | "__root__"
116− | "/"
117− | "/billing"
118− | "/secrets"
119− | "/team"
120− | "/tools"
121− | "/billing_/plans"
122− | "/sources/$namespace"
123− | "/sources/add/$pluginKey";
124− fileRoutesById: FileRoutesById;
115+ | '__root__'
116+ | '/'
117+ | '/billing'
118+ | '/secrets'
119+ | '/team'
120+ | '/tools'
121+ | '/billing_/plans'
122+ | '/sources/$namespace'
123+ | '/sources/add/$pluginKey'
124+ fileRoutesById: FileRoutesById
125125 }
126126 export interface RootRouteChildren {
127− IndexRoute: typeof IndexRoute;
128− BillingRoute: typeof BillingRoute;
129− SecretsRoute: typeof SecretsRoute;
130− TeamRoute: typeof TeamRoute;
131− ToolsRoute: typeof ToolsRoute;
132− BillingPlansRoute: typeof BillingPlansRoute;
133− SourcesNamespaceRoute: typeof SourcesNamespaceRoute;
134− SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute;
127+ IndexRoute: typeof IndexRoute
128+ BillingRoute: typeof BillingRoute
129+ SecretsRoute: typeof SecretsRoute
130+ TeamRoute: typeof TeamRoute
131+ ToolsRoute: typeof ToolsRoute
132+ BillingPlansRoute: typeof BillingPlansRoute
133+ SourcesNamespaceRoute: typeof SourcesNamespaceRoute
134+ SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute
135135 }
136136
137−declare module "@tanstack/react-router" {
137+declare module '@tanstack/react-router' {
138138 interface FileRoutesByPath {
139− "/tools": {
140− id: "/tools";
141− path: "/tools";
142− fullPath: "/tools";
143− preLoaderRoute: typeof ToolsRouteImport;
144− parentRoute: typeof rootRouteImport;
145− };
146− "/team": {
147− id: "/team";
148− path: "/team";
149− fullPath: "/team";
150− preLoaderRoute: typeof TeamRouteImport;
151− parentRoute: typeof rootRouteImport;
152− };
153− "/secrets": {
154− id: "/secrets";
155− path: "/secrets";
156− fullPath: "/secrets";
157− preLoaderRoute: typeof SecretsRouteImport;
158− parentRoute: typeof rootRouteImport;
159− };
160− "/billing": {
161− id: "/billing";
162− path: "/billing";
163− fullPath: "/billing";
164− preLoaderRoute: typeof BillingRouteImport;
165− parentRoute: typeof rootRouteImport;
166− };
167− "/": {
168− id: "/";
169− path: "/";
170− fullPath: "/";
171− preLoaderRoute: typeof IndexRouteImport;
172− parentRoute: typeof rootRouteImport;
173− };
174− "/sources/$namespace": {
175− id: "/sources/$namespace";
176− path: "/sources/$namespace";
177− fullPath: "/sources/$namespace";
178− preLoaderRoute: typeof SourcesNamespaceRouteImport;
179− parentRoute: typeof rootRouteImport;
180− };
181− "/billing_/plans": {
182− id: "/billing_/plans";
183− path: "/billing/plans";
184− fullPath: "/billing/plans";
185− preLoaderRoute: typeof BillingPlansRouteImport;
186− parentRoute: typeof rootRouteImport;
187− };
188− "/sources/add/$pluginKey": {
189− id: "/sources/add/$pluginKey";
190− path: "/sources/add/$pluginKey";
191− fullPath: "/sources/add/$pluginKey";
192− preLoaderRoute: typeof SourcesAddPluginKeyRouteImport;
193− parentRoute: typeof rootRouteImport;
194− };
139+ '/tools': {
140+ id: '/tools'
141+ path: '/tools'
142+ fullPath: '/tools'
143+ preLoaderRoute: typeof ToolsRouteImport
144+ parentRoute: typeof rootRouteImport
145+ }
146+ '/team': {
147+ id: '/team'
148+ path: '/team'
149+ fullPath: '/team'
150+ preLoaderRoute: typeof TeamRouteImport
151+ parentRoute: typeof rootRouteImport
152+ }
153+ '/secrets': {
154+ id: '/secrets'
155+ path: '/secrets'
156+ fullPath: '/secrets'
157+ preLoaderRoute: typeof SecretsRouteImport
158+ parentRoute: typeof rootRouteImport
159+ }
160+ '/billing': {
161+ id: '/billing'
162+ path: '/billing'
163+ fullPath: '/billing'
164+ preLoaderRoute: typeof BillingRouteImport
165+ parentRoute: typeof rootRouteImport
166+ }
167+ '/': {
168+ id: '/'
169+ path: '/'
170+ fullPath: '/'
171+ preLoaderRoute: typeof IndexRouteImport
172+ parentRoute: typeof rootRouteImport
173+ }
174+ '/sources/$namespace': {
175+ id: '/sources/$namespace'
176+ path: '/sources/$namespace'
177+ fullPath: '/sources/$namespace'
178+ preLoaderRoute: typeof SourcesNamespaceRouteImport
179+ parentRoute: typeof rootRouteImport
180+ }
181+ '/billing_/plans': {
182+ id: '/billing_/plans'
183+ path: '/billing/plans'
184+ fullPath: '/billing/plans'
185+ preLoaderRoute: typeof BillingPlansRouteImport
186+ parentRoute: typeof rootRouteImport
187+ }
188+ '/sources/add/$pluginKey': {
189+ id: '/sources/add/$pluginKey'
190+ path: '/sources/add/$pluginKey'
191+ fullPath: '/sources/add/$pluginKey'
192+ preLoaderRoute: typeof SourcesAddPluginKeyRouteImport
193+ parentRoute: typeof rootRouteImport
194+ }
195195 }
196196 }
197197
@@ -204,17 +204,17 @@ const rootRouteChildren: RootRouteChildren = { · 6 unmodified lines
204204 BillingPlansRoute: BillingPlansRoute,
205205 SourcesNamespaceRoute: SourcesNamespaceRoute,
206206 SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute,
207−};
207+}
208208 export const routeTree = rootRouteImport
209209 ._addFileChildren(rootRouteChildren)
210− ._addFileTypes<FileRouteTypes>();
210+ ._addFileTypes<FileRouteTypes>()
211211
212−import type { getRouter } from "./router.tsx";
213−import type { startInstance } from "./start.ts";
214−declare module "@tanstack/react-start" {
212+import type { getRouter } from './router.tsx'
213+import type { startInstance } from './start.ts'
214+declare module '@tanstack/react-start' {
215215 interface Register {
216− ssr: true;
217− router: Awaited<ReturnType<typeof getRouter>>;
218− config: Awaited<ReturnType<typeof startInstance.getOptions>>;
216+ ssr: true
217+ router: Awaited<ReturnType<typeof getRouter>>
218+ config: Awaited<ReturnType<typeof startInstance.getOptions>>
219219 }
220220 }
apps/cloud/vitest.unit.config.ts+8−0
@@ -0,0 +1,8 @@
1+import { defineConfig } from "vitest/config";
2+
3+export 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
6969 }
7070 return response;
7171 } catch (error) {
72+ console.error("[mcp] handleRequest error:", error instanceof Error ? error.stack : error);
7273 if (!transport.sessionId) {
7374 await transport.close().catch(() => undefined);
7475 await created?.close().catch(() => undefined);
7576 }
76− return jsonError(
77− 500,
78− -32603,
79− error instanceof Error ? error.message : "Internal server error",
80− );
77+ return jsonError(500, -32603, "Internal server error");
8178 }
8279 },
8380
bun.lock+6−5
@@ -87,6 +87,7 @@ · 86 unmodified lines
8787 "devDependencies": {
8888 "@cloudflare/vitest-pool-workers": "^0.14.3",
8989 "@cloudflare/workers-types": "^4.20250620.0",
90+ "@effect/platform-node": "catalog:",
9091 "@effect/vitest": "^0.29.0",
9192 "@electric-sql/pglite": "^0.4.3",
9293 "@electric-sql/pglite-socket": "^0.1.3",
@@ -3867,7 +3868,7 @@ · 3774 unmodified lines
38673868
38683869 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
38693870
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=="],
38713872
38723873 "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
38733874
@@ -3921,14 +3922,12 @@ · 47 unmodified lines
39213922
39223923 "@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=="],
39233924
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+
39243927 "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
39253928
39263929 "@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=="],
39273930
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−
39323931 "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
39333932
39343933 "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -4229,6 +4228,8 @@ · 294 unmodified lines
42294228
42304229 "miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
42314230
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+
42324233 "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
42334234
42344235 "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
9393
9494 const HtmlResponse = HttpApiSchema.Text({ contentType: "text/html" });
9595
96−const ApiError = Schema.Struct({
97− message: Schema.String,
98−}).annotations(HttpApiSchema.annotations({ status: 400 }));
96+export class GoogleDiscoveryApiError extends Schema.TaggedError<GoogleDiscoveryApiError>()(
97+ "GoogleDiscoveryApiError",
98+ {
99+ message: Schema.String,
100+ },
101+ HttpApiSchema.annotations({ status: 400 }),
102+) {}
103+
104+export class GoogleDiscoveryInternalError extends Schema.TaggedError<GoogleDiscoveryInternalError>()(
105+ "GoogleDiscoveryInternalError",
106+ {
107+ message: Schema.String,
108+ },
109+ HttpApiSchema.annotations({ status: 500 }),
110+) {}
99111
100112 export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")
101113 .add(
102114 HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe`
103115 .setPayload(ProbePayload)
104116 .addSuccess(ProbeResponse)
105− .addError(ApiError),
117+ .addError(GoogleDiscoveryApiError)
118+ .addError(GoogleDiscoveryInternalError),
106119 )
107120 .add(
108121 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources`
109122 .setPayload(AddSourcePayload)
110123 .addSuccess(AddSourceResponse)
111− .addError(ApiError),
124+ .addError(GoogleDiscoveryApiError)
125+ .addError(GoogleDiscoveryInternalError),
112126 )
113127 .add(
114128 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`
115129 .setPayload(StartOAuthPayload)
116130 .addSuccess(StartOAuthResponse)
117− .addError(ApiError),
131+ .addError(GoogleDiscoveryApiError)
132+ .addError(GoogleDiscoveryInternalError),
118133 )
119134 .add(
120135 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete`
121136 .setPayload(CompleteOAuthPayload)
122137 .addSuccess(CompleteOAuthResponse)
123− .addError(ApiError),
138+ .addError(GoogleDiscoveryApiError)
139+ .addError(GoogleDiscoveryInternalError),
124140 )
125141 .add(
126142 HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback`
127143 .setUrlParams(OAuthCallbackParams)
128144 .addSuccess(HtmlResponse)
129− .addError(ApiError),
145+ .addError(GoogleDiscoveryApiError)
146+ .addError(GoogleDiscoveryInternalError),
130147 )
131148 .add(
132149 HttpApiEndpoint.get(
133150 "getSource",
134151 )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`
135152 .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema))
136− .addError(ApiError),
153+ .addError(GoogleDiscoveryApiError)
154+ .addError(GoogleDiscoveryInternalError),
137155 ) {}
packages/plugins/google-discovery/src/api/handlers.test.ts+66−0
@@ -0,0 +1,66 @@
1+import { HttpApiBuilder, HttpServer } from "@effect/platform";
2+import { describe, expect, it } from "vitest";
3+import { Effect, Layer } from "effect";
4+
5+import { addGroup } from "@executor/api";
6+import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
7+import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";
8+import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";
9+import { GoogleDiscoveryGroup } from "./group";
10+
11+const unused = Effect.dieMessage("unused");
12+
13+const 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+
22+const Api = addGroup(GoogleDiscoveryGroup);
23+
24+const fakeExecutor = {} as any;
25+const fakeExecutionEngine = {} as any;
26+
27+const 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+
41+describe("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 @@
11 import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2−import { Context, Effect } from "effect";
2+import { Cause, Context, Effect } from "effect";
33
44 import { addGroup } from "@executor/api";
55 import type {
@@ -7,7 +7,17 @@ import type { · 1 unmodified line
77 GoogleDiscoveryOAuthAuthResult,
88 GoogleDiscoveryPluginExtension,
99 } from "../sdk/plugin";
10−import { GoogleDiscoveryGroup } from "./group";
10+import {
11+ GoogleDiscoveryInvocationError,
12+ GoogleDiscoveryOAuthError,
13+ GoogleDiscoveryParseError,
14+ GoogleDiscoverySourceError,
15+} from "../sdk/errors";
16+import {
17+ GoogleDiscoveryApiError,
18+ GoogleDiscoveryGroup,
19+ GoogleDiscoveryInternalError,
20+} from "./group";
1121
1222 export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<
1323 GoogleDiscoveryExtensionService,
@@ -66,6 +76,37 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 52 unmodified lines
6676 </body></html>`;
6777 };
6878
79+const toPopupErrorMessage = (error: unknown): string => {
80+ if (error instanceof GoogleDiscoveryOAuthError) {
81+ return error.message;
82+ }
83+ return "Authentication failed";
84+};
85+
86+const 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+
103+const 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+
69110 export const GoogleDiscoveryHandlers = HttpApiBuilder.group(
70111 ExecutorApiWithGoogleDiscovery,
71112 "googleDiscovery",
@@ -75,13 +116,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines
75116 Effect.gen(function* () {
76117 const ext = yield* GoogleDiscoveryExtensionService;
77118 return yield* ext.probeDiscovery(payload.discoveryUrl);
78− }).pipe(Effect.orDie),
119+ }).pipe(sanitizeGoogleDiscoveryFailure),
79120 )
80121 .handle("addSource", ({ payload }) =>
81122 Effect.gen(function* () {
82123 const ext = yield* GoogleDiscoveryExtensionService;
83124 return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput);
84− }).pipe(Effect.orDie),
125+ }).pipe(sanitizeGoogleDiscoveryFailure),
85126 )
86127 .handle("startOAuth", ({ payload }) =>
87128 Effect.gen(function* () {
@@ -94,7 +135,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 6 unmodified lines
94135 redirectUrl: payload.redirectUrl,
95136 scopes: payload.scopes,
96137 });
97− }).pipe(Effect.orDie),
138+ }).pipe(sanitizeGoogleDiscoveryFailure),
98139 )
99140 .handle("completeOAuth", ({ payload }) =>
100141 Effect.gen(function* () {
@@ -104,13 +145,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines
104145 code: payload.code,
105146 error: payload.error,
106147 });
107− }).pipe(Effect.orDie),
148+ }).pipe(sanitizeGoogleDiscoveryFailure),
108149 )
109150 .handle("getSource", ({ path }) =>
110151 Effect.gen(function* () {
111152 const ext = yield* GoogleDiscoveryExtensionService;
112153 return yield* ext.getSource(path.namespace);
113− }).pipe(Effect.orDie),
154+ }).pipe(sanitizeGoogleDiscoveryFailure),
114155 )
115156 .handle("oauthCallback", ({ urlParams }) =>
116157 Effect.gen(function* () {
@@ -130,16 +171,16 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 13 unmodified lines
130171 ...auth,
131172 }),
132173 ),
133− Effect.catchAll((error) =>
174+ Effect.catchAllCause((cause) =>
134175 Effect.succeed<OAuthPopupResult>({
135176 type: "executor:oauth-result",
136177 ok: false,
137178 sessionId: null,
138− error: error instanceof Error ? error.message : String(error),
179+ error: toPopupErrorMessage(Cause.squash(cause)),
139180 }),
140181 ),
141182 );
142183 return yield* HttpServerResponse.html(popupDocument(result));
143− }).pipe(Effect.orDie),
184+ }).pipe(sanitizeGoogleDiscoveryFailure),
144185 ),
145186 );
packages/plugins/mcp/src/api/group.ts+33−12
@@ -152,9 +152,21 @@ const CompleteOAuthResponse = Schema.Struct({ · 151 unmodified lines
152152 // Error
153153 // ---------------------------------------------------------------------------
154154
155−const McpApiError = Schema.Struct({
156− message: Schema.String,
157−}).annotations(HttpApiSchema.annotations({ status: 400 }));
155+export class McpApiError extends Schema.TaggedError<McpApiError>()(
156+ "McpApiError",
157+ {
158+ message: Schema.String,
159+ },
160+ HttpApiSchema.annotations({ status: 400 }),
161+) {}
162+
163+export class McpInternalError extends Schema.TaggedError<McpInternalError>()(
164+ "McpInternalError",
165+ {
166+ message: Schema.String,
167+ },
168+ HttpApiSchema.annotations({ status: 500 }),
169+) {}
158170
159171 // ---------------------------------------------------------------------------
160172 // Group
@@ -165,52 +177,61 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 4 unmodified lines
165177 HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe`
166178 .setPayload(ProbeEndpointPayload)
167179 .addSuccess(ProbeEndpointResponse)
168− .addError(McpApiError),
180+ .addError(McpApiError)
181+ .addError(McpInternalError),
169182 )
170183 .add(
171184 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources`
172185 .setPayload(AddSourcePayload)
173186 .addSuccess(AddSourceResponse)
174− .addError(McpApiError),
187+ .addError(McpApiError)
188+ .addError(McpInternalError),
175189 )
176190 .add(
177191 HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove`
178192 .setPayload(NamespacePayload)
179193 .addSuccess(RemoveSourceResponse)
180− .addError(McpApiError),
194+ .addError(McpApiError)
195+ .addError(McpInternalError),
181196 )
182197 .add(
183198 HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh`
184199 .setPayload(NamespacePayload)
185200 .addSuccess(RefreshSourceResponse)
186− .addError(McpApiError),
201+ .addError(McpApiError)
202+ .addError(McpInternalError),
187203 )
188204 .add(
189205 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start`
190206 .setPayload(StartOAuthPayload)
191207 .addSuccess(StartOAuthResponse)
192− .addError(McpApiError),
208+ .addError(McpApiError)
209+ .addError(McpInternalError),
193210 )
194211 .add(
195212 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete`
196213 .setPayload(CompleteOAuthPayload)
197214 .addSuccess(CompleteOAuthResponse)
198− .addError(McpApiError),
215+ .addError(McpApiError)
216+ .addError(McpInternalError),
199217 )
200218 .add(
201219 HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback`
202220 .setUrlParams(OAuthCallbackParams)
203221 .addSuccess(HtmlResponse)
204− .addError(McpApiError),
222+ .addError(McpApiError)
223+ .addError(McpInternalError),
205224 )
206225 .add(
207226 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
208227 .addSuccess(Schema.NullOr(McpStoredSourceSchema))
209− .addError(McpApiError),
228+ .addError(McpApiError)
229+ .addError(McpInternalError),
210230 )
211231 .add(
212232 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
213233 .setPayload(UpdateSourcePayload)
214234 .addSuccess(UpdateSourceResponse)
215− .addError(McpApiError),
235+ .addError(McpApiError)
236+ .addError(McpInternalError),
216237 ) {}
packages/plugins/mcp/src/api/handlers.test.ts+66−0
@@ -0,0 +1,66 @@
1+import { HttpApiBuilder, HttpServer } from "@effect/platform";
2+import { describe, expect, it } from "vitest";
3+import { Effect, Layer } from "effect";
4+
5+import { addGroup } from "@executor/api";
6+import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
7+import type { McpPluginExtension } from "../sdk/plugin";
8+import { McpExtensionService, McpHandlers } from "./handlers";
9+import { McpGroup } from "./group";
10+
11+const unused = Effect.dieMessage("unused");
12+
13+const 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+
24+const Api = addGroup(McpGroup);
25+
26+const fakeExecutor = {} as any;
27+const fakeExecutionEngine = {} as any;
28+
29+const 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+
43+describe("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 @@
11 import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2−import { Context, Effect } from "effect";
2+import { Cause, Context, Effect } from "effect";
33
44 import { addGroup } from "@executor/api";
55 import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";
6−import { McpGroup } from "./group";
6+import {
7+ McpConnectionError,
8+ McpInvocationError,
9+ McpOAuthError,
10+ McpToolDiscoveryError,
11+} from "../sdk/errors";
12+import { McpApiError, McpGroup, McpInternalError } from "./group";
713
814 // ---------------------------------------------------------------------------
915 // Service tag
@@ -79,6 +85,33 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 69 unmodified lines
7985 </body></html>`;
8086 };
8187
88+const toPopupErrorMessage = (error: unknown): string => {
89+ if (error instanceof McpOAuthError) {
90+ return error.message;
91+ }
92+ return "Authentication failed";
93+};
94+
95+const 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+
110+const 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+
82115 // ---------------------------------------------------------------------------
83116 // Convert API payload → McpSourceConfig
84117 // ---------------------------------------------------------------------------
@@ -150,7 +183,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 65 unmodified lines
150183 Effect.gen(function* () {
151184 const ext = yield* McpExtensionService;
152185 return yield* ext.probeEndpoint(payload.endpoint);
153− }).pipe(Effect.orDie),
186+ }).pipe(sanitizeMcpFailure),
154187 )
155188 .handle("addSource", ({ payload }) =>
156189 Effect.gen(function* () {
@@ -158,20 +191,20 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 1 unmodified line
158191 return yield* ext.addSource(
159192 toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]),
160193 );
161− }).pipe(Effect.orDie),
194+ }).pipe(sanitizeMcpFailure),
162195 )
163196 .handle("removeSource", ({ payload }) =>
164197 Effect.gen(function* () {
165198 const ext = yield* McpExtensionService;
166199 yield* ext.removeSource(payload.namespace);
167200 return { removed: true };
168− }).pipe(Effect.orDie),
201+ }).pipe(sanitizeMcpFailure),
169202 )
170203 .handle("refreshSource", ({ payload }) =>
171204 Effect.gen(function* () {
172205 const ext = yield* McpExtensionService;
173206 return yield* ext.refreshSource(payload.namespace);
174− }).pipe(Effect.orDie),
207+ }).pipe(sanitizeMcpFailure),
175208 )
176209 .handle("startOAuth", ({ payload }) =>
177210 Effect.gen(function* () {
@@ -181,7 +214,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines
181214 redirectUrl: payload.redirectUrl,
182215 queryParams: payload.queryParams,
183216 });
184− }).pipe(Effect.orDie),
217+ }).pipe(sanitizeMcpFailure),
185218 )
186219 .handle("completeOAuth", ({ payload }) =>
187220 Effect.gen(function* () {
@@ -191,13 +224,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines
191224 code: payload.code,
192225 error: payload.error,
193226 });
194− }).pipe(Effect.orDie),
227+ }).pipe(sanitizeMcpFailure),
195228 )
196229 .handle("getSource", ({ path }) =>
197230 Effect.gen(function* () {
198231 const ext = yield* McpExtensionService;
199232 return yield* ext.getSource(path.namespace);
200− }).pipe(Effect.orDie),
233+ }).pipe(sanitizeMcpFailure),
201234 )
202235 .handle("updateSource", ({ path, payload }) =>
203236 Effect.gen(function* () {
@@ -209,7 +242,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 5 unmodified lines
209242 auth: payload.auth as McpUpdateSourceInput["auth"],
210243 });
211244 return { updated: true };
212− }).pipe(Effect.orDie),
245+ }).pipe(sanitizeMcpFailure),
213246 )
214247 .handle("oauthCallback", ({ urlParams }) =>
215248 Effect.gen(function* () {
@@ -233,16 +266,16 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 17 unmodified lines
233266 scope: c.scope,
234267 }),
235268 ),
236− Effect.catchAll((error) =>
269+ Effect.catchAllCause((cause) =>
237270 Effect.succeed<OAuthPopupResult>({
238271 type: "executor:oauth-result",
239272 ok: false,
240273 sessionId: null,
241− error: error instanceof Error ? error.message : String(error),
274+ error: toPopupErrorMessage(Cause.squash(cause)),
242275 }),
243276 ),
244277 );
245278 return yield* HttpServerResponse.html(popupDocument(result));
246− }).pipe(Effect.orDie),
279+ }).pipe(sanitizeMcpFailure),
247280 ),
248281 );