Unified · Split
| @@ -11,20 +11,6 @@ concurrency: · 10 unmodified lines | ||
| 11 | 11 | cancel-in-progress: true |
| 12 | 12 | |
| 13 | 13 | 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 | − | |
| 28 | 14 | lint: |
| 29 | 15 | name: Lint |
| 30 | 16 | runs-on: ubuntu-latest |
| @@ -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" | |
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "$schema": "./node_modules/oxfmt/configuration_schema.json", |
| 3 | − "ignorePatterns": [] | |
| 3 | + "ignorePatterns": ["**/routeTree.gen.ts"] | |
| 4 | 4 | } |
| @@ -16,5 +16,5 @@ · 15 unmodified lines | ||
| 16 | 16 | }, |
| 17 | 17 | ], |
| 18 | 18 | }, |
| 19 | − "ignorePatterns": [".astro/"], | |
| 19 | + "ignorePatterns": [".astro/", "**/routeTree.gen.ts"], | |
| 20 | 20 | } |
| @@ -61,6 +61,7 @@ · 60 unmodified lines | ||
| 61 | 61 | "devDependencies": { |
| 62 | 62 | "@cloudflare/vitest-pool-workers": "^0.14.3", |
| 63 | 63 | "@cloudflare/workers-types": "^4.20250620.0", |
| 64 | + "@effect/platform-node": "catalog:", | |
| 64 | 65 | "@effect/vitest": "^0.29.0", |
| 65 | 66 | "@electric-sql/pglite": "^0.4.3", |
| 66 | 67 | "@electric-sql/pglite-socket": "^0.1.3", |
| @@ -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 | +}); | |
| @@ -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"; | |
| 14 | 1 | 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"; | |
| 42 | 5 | 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 }), | |
| 95 | 18 | ); |
| 96 | 19 | |
| 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), | |
| 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 | − | |
| 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 | −}; | |
| @@ -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; | |
| @@ -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 | +}; | |
| @@ -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 | +}); | |
| @@ -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)); | |
| @@ -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 | +}); | |
| @@ -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; | |
| @@ -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); | |
| @@ -87,7 +87,12 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 86 unmodified lines | ||
| 87 | 87 | |
| 88 | 88 | setCookie("wos-session", sealedSession, COOKIE_OPTIONS); |
| 89 | 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 | |
| @@ -143,7 +143,7 @@ export class McpSessionDO extends DurableObject { · 142 unmodified lines | ||
| 143 | 143 | return await this.transport.handleRequest(request); |
| 144 | 144 | } catch (err) { |
| 145 | 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 | |
| @@ -128,7 +128,7 @@ const handleMcpRequest_POST = async (request: Request, token: VerifiedToken): Pr · 127 unmodified lines | ||
| 128 | 128 | return await stub.handleRequest(request); |
| 129 | 129 | } catch (err) { |
| 130 | 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 | |
| @@ -8,190 +8,190 @@ · 7 unmodified lines | ||
| 8 | 8 | // You should NOT make any changes in this file as it will be overwritten. |
| 9 | 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. |
| 10 | 10 | |
| 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' | |
| 20 | 20 | |
| 21 | 21 | const ToolsRoute = ToolsRouteImport.update({ |
| 22 | − id: "/tools", | |
| 23 | − path: "/tools", | |
| 22 | + id: '/tools', | |
| 23 | + path: '/tools', | |
| 24 | 24 | getParentRoute: () => rootRouteImport, |
| 25 | −} as any); | |
| 25 | +} as any) | |
| 26 | 26 | const TeamRoute = TeamRouteImport.update({ |
| 27 | − id: "/team", | |
| 28 | − path: "/team", | |
| 27 | + id: '/team', | |
| 28 | + path: '/team', | |
| 29 | 29 | getParentRoute: () => rootRouteImport, |
| 30 | −} as any); | |
| 30 | +} as any) | |
| 31 | 31 | const SecretsRoute = SecretsRouteImport.update({ |
| 32 | − id: "/secrets", | |
| 33 | − path: "/secrets", | |
| 32 | + id: '/secrets', | |
| 33 | + path: '/secrets', | |
| 34 | 34 | getParentRoute: () => rootRouteImport, |
| 35 | −} as any); | |
| 35 | +} as any) | |
| 36 | 36 | const BillingRoute = BillingRouteImport.update({ |
| 37 | − id: "/billing", | |
| 38 | − path: "/billing", | |
| 37 | + id: '/billing', | |
| 38 | + path: '/billing', | |
| 39 | 39 | getParentRoute: () => rootRouteImport, |
| 40 | −} as any); | |
| 40 | +} as any) | |
| 41 | 41 | const IndexRoute = IndexRouteImport.update({ |
| 42 | − id: "/", | |
| 43 | − path: "/", | |
| 42 | + id: '/', | |
| 43 | + path: '/', | |
| 44 | 44 | getParentRoute: () => rootRouteImport, |
| 45 | −} as any); | |
| 45 | +} as any) | |
| 46 | 46 | const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ |
| 47 | − id: "/sources/$namespace", | |
| 48 | − path: "/sources/$namespace", | |
| 47 | + id: '/sources/$namespace', | |
| 48 | + path: '/sources/$namespace', | |
| 49 | 49 | getParentRoute: () => rootRouteImport, |
| 50 | −} as any); | |
| 50 | +} as any) | |
| 51 | 51 | const BillingPlansRoute = BillingPlansRouteImport.update({ |
| 52 | − id: "/billing_/plans", | |
| 53 | − path: "/billing/plans", | |
| 52 | + id: '/billing_/plans', | |
| 53 | + path: '/billing/plans', | |
| 54 | 54 | getParentRoute: () => rootRouteImport, |
| 55 | −} as any); | |
| 55 | +} as any) | |
| 56 | 56 | const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ |
| 57 | − id: "/sources/add/$pluginKey", | |
| 58 | − path: "/sources/add/$pluginKey", | |
| 57 | + id: '/sources/add/$pluginKey', | |
| 58 | + path: '/sources/add/$pluginKey', | |
| 59 | 59 | getParentRoute: () => rootRouteImport, |
| 60 | −} as any); | |
| 60 | +} as any) | |
| 61 | 61 | |
| 62 | 62 | 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 | |
| 71 | 71 | } |
| 72 | 72 | 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 | |
| 81 | 81 | } |
| 82 | 82 | 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 | |
| 92 | 92 | } |
| 93 | 93 | export interface FileRouteTypes { |
| 94 | − fileRoutesByFullPath: FileRoutesByFullPath; | |
| 94 | + fileRoutesByFullPath: FileRoutesByFullPath | |
| 95 | 95 | 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 | |
| 105 | 105 | 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' | |
| 114 | 114 | 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 | |
| 125 | 125 | } |
| 126 | 126 | 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 | |
| 135 | 135 | } |
| 136 | 136 | |
| 137 | −declare module "@tanstack/react-router" { | |
| 137 | +declare module '@tanstack/react-router' { | |
| 138 | 138 | 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 | + } | |
| 195 | 195 | } |
| 196 | 196 | } |
| 197 | 197 | |
| @@ -204,17 +204,17 @@ const rootRouteChildren: RootRouteChildren = { · 6 unmodified lines | ||
| 204 | 204 | BillingPlansRoute: BillingPlansRoute, |
| 205 | 205 | SourcesNamespaceRoute: SourcesNamespaceRoute, |
| 206 | 206 | SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, |
| 207 | −}; | |
| 207 | +} | |
| 208 | 208 | export const routeTree = rootRouteImport |
| 209 | 209 | ._addFileChildren(rootRouteChildren) |
| 210 | − ._addFileTypes<FileRouteTypes>(); | |
| 210 | + ._addFileTypes<FileRouteTypes>() | |
| 211 | 211 | |
| 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' { | |
| 215 | 215 | 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>> | |
| 219 | 219 | } |
| 220 | 220 | } |
| @@ -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 | +}); | |
| @@ -69,15 +69,12 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 68 unmodified lines | ||
| 69 | 69 | } |
| 70 | 70 | return response; |
| 71 | 71 | } catch (error) { |
| 72 | + console.error("[mcp] handleRequest error:", error instanceof Error ? error.stack : error); | |
| 72 | 73 | if (!transport.sessionId) { |
| 73 | 74 | await transport.close().catch(() => undefined); |
| 74 | 75 | await created?.close().catch(() => undefined); |
| 75 | 76 | } |
| 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"); | |
| 81 | 78 | } |
| 82 | 79 | }, |
| 83 | 80 | |
| @@ -87,6 +87,7 @@ · 86 unmodified lines | ||
| 87 | 87 | "devDependencies": { |
| 88 | 88 | "@cloudflare/vitest-pool-workers": "^0.14.3", |
| 89 | 89 | "@cloudflare/workers-types": "^4.20250620.0", |
| 90 | + "@effect/platform-node": "catalog:", | |
| 90 | 91 | "@effect/vitest": "^0.29.0", |
| 91 | 92 | "@electric-sql/pglite": "^0.4.3", |
| 92 | 93 | "@electric-sql/pglite-socket": "^0.1.3", |
| @@ -3867,7 +3868,7 @@ · 3774 unmodified lines | ||
| 3867 | 3868 | |
| 3868 | 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 | 3873 | "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], |
| 3873 | 3874 | |
| @@ -3921,14 +3922,12 @@ · 47 unmodified lines | ||
| 3921 | 3922 | |
| 3922 | 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 | 3927 | "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| 3925 | 3928 | |
| 3926 | 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 | 3931 | "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], |
| 3933 | 3932 | |
| 3934 | 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 | 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 | 4233 | "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], |
| 4233 | 4234 | |
| 4234 | 4235 | "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], |
| @@ -93,45 +93,63 @@ const OAuthCallbackParams = Schema.Struct({ · 92 unmodified lines | ||
| 93 | 93 | |
| 94 | 94 | const HtmlResponse = HttpApiSchema.Text({ contentType: "text/html" }); |
| 95 | 95 | |
| 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 | +) {} | |
| 99 | 111 | |
| 100 | 112 | export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") |
| 101 | 113 | .add( |
| 102 | 114 | HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe` |
| 103 | 115 | .setPayload(ProbePayload) |
| 104 | 116 | .addSuccess(ProbeResponse) |
| 105 | − .addError(ApiError), | |
| 117 | + .addError(GoogleDiscoveryApiError) | |
| 118 | + .addError(GoogleDiscoveryInternalError), | |
| 106 | 119 | ) |
| 107 | 120 | .add( |
| 108 | 121 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources` |
| 109 | 122 | .setPayload(AddSourcePayload) |
| 110 | 123 | .addSuccess(AddSourceResponse) |
| 111 | − .addError(ApiError), | |
| 124 | + .addError(GoogleDiscoveryApiError) | |
| 125 | + .addError(GoogleDiscoveryInternalError), | |
| 112 | 126 | ) |
| 113 | 127 | .add( |
| 114 | 128 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start` |
| 115 | 129 | .setPayload(StartOAuthPayload) |
| 116 | 130 | .addSuccess(StartOAuthResponse) |
| 117 | − .addError(ApiError), | |
| 131 | + .addError(GoogleDiscoveryApiError) | |
| 132 | + .addError(GoogleDiscoveryInternalError), | |
| 118 | 133 | ) |
| 119 | 134 | .add( |
| 120 | 135 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete` |
| 121 | 136 | .setPayload(CompleteOAuthPayload) |
| 122 | 137 | .addSuccess(CompleteOAuthResponse) |
| 123 | − .addError(ApiError), | |
| 138 | + .addError(GoogleDiscoveryApiError) | |
| 139 | + .addError(GoogleDiscoveryInternalError), | |
| 124 | 140 | ) |
| 125 | 141 | .add( |
| 126 | 142 | HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback` |
| 127 | 143 | .setUrlParams(OAuthCallbackParams) |
| 128 | 144 | .addSuccess(HtmlResponse) |
| 129 | − .addError(ApiError), | |
| 145 | + .addError(GoogleDiscoveryApiError) | |
| 146 | + .addError(GoogleDiscoveryInternalError), | |
| 130 | 147 | ) |
| 131 | 148 | .add( |
| 132 | 149 | HttpApiEndpoint.get( |
| 133 | 150 | "getSource", |
| 134 | 151 | )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}` |
| 135 | 152 | .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)) |
| 136 | − .addError(ApiError), | |
| 153 | + .addError(GoogleDiscoveryApiError) | |
| 154 | + .addError(GoogleDiscoveryInternalError), | |
| 137 | 155 | ) {} |
| @@ -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 | +}); | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | −import { Context, Effect } from "effect"; | |
| 2 | +import { Cause, Context, Effect } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { addGroup } from "@executor/api"; |
| 5 | 5 | import type { |
| @@ -7,7 +7,17 @@ import type { · 1 unmodified line | ||
| 7 | 7 | GoogleDiscoveryOAuthAuthResult, |
| 8 | 8 | GoogleDiscoveryPluginExtension, |
| 9 | 9 | } 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"; | |
| 11 | 21 | |
| 12 | 22 | export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")< |
| 13 | 23 | GoogleDiscoveryExtensionService, |
| @@ -66,6 +76,37 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 52 unmodified lines | ||
| 66 | 76 | </body></html>`; |
| 67 | 77 | }; |
| 68 | 78 | |
| 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 | + | |
| 69 | 110 | export const GoogleDiscoveryHandlers = HttpApiBuilder.group( |
| 70 | 111 | ExecutorApiWithGoogleDiscovery, |
| 71 | 112 | "googleDiscovery", |
| @@ -75,13 +116,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines | ||
| 75 | 116 | Effect.gen(function* () { |
| 76 | 117 | const ext = yield* GoogleDiscoveryExtensionService; |
| 77 | 118 | return yield* ext.probeDiscovery(payload.discoveryUrl); |
| 78 | − }).pipe(Effect.orDie), | |
| 119 | + }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 79 | 120 | ) |
| 80 | 121 | .handle("addSource", ({ payload }) => |
| 81 | 122 | Effect.gen(function* () { |
| 82 | 123 | const ext = yield* GoogleDiscoveryExtensionService; |
| 83 | 124 | return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput); |
| 84 | − }).pipe(Effect.orDie), | |
| 125 | + }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 85 | 126 | ) |
| 86 | 127 | .handle("startOAuth", ({ payload }) => |
| 87 | 128 | Effect.gen(function* () { |
| @@ -94,7 +135,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 6 unmodified lines | ||
| 94 | 135 | redirectUrl: payload.redirectUrl, |
| 95 | 136 | scopes: payload.scopes, |
| 96 | 137 | }); |
| 97 | − }).pipe(Effect.orDie), | |
| 138 | + }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 98 | 139 | ) |
| 99 | 140 | .handle("completeOAuth", ({ payload }) => |
| 100 | 141 | Effect.gen(function* () { |
| @@ -104,13 +145,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines | ||
| 104 | 145 | code: payload.code, |
| 105 | 146 | error: payload.error, |
| 106 | 147 | }); |
| 107 | − }).pipe(Effect.orDie), | |
| 148 | + }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 108 | 149 | ) |
| 109 | 150 | .handle("getSource", ({ path }) => |
| 110 | 151 | Effect.gen(function* () { |
| 111 | 152 | const ext = yield* GoogleDiscoveryExtensionService; |
| 112 | 153 | return yield* ext.getSource(path.namespace); |
| 113 | − }).pipe(Effect.orDie), | |
| 154 | + }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 114 | 155 | ) |
| 115 | 156 | .handle("oauthCallback", ({ urlParams }) => |
| 116 | 157 | Effect.gen(function* () { |
| @@ -130,16 +171,16 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 13 unmodified lines | ||
| 130 | 171 | ...auth, |
| 131 | 172 | }), |
| 132 | 173 | ), |
| 133 | − Effect.catchAll((error) => | |
| 174 | + Effect.catchAllCause((cause) => | |
| 134 | 175 | Effect.succeed<OAuthPopupResult>({ |
| 135 | 176 | type: "executor:oauth-result", |
| 136 | 177 | ok: false, |
| 137 | 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 | 183 | return yield* HttpServerResponse.html(popupDocument(result)); |
| 143 | − }).pipe(Effect.orDie), | |
| 184 | + }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 144 | 185 | ), |
| 145 | 186 | ); |
| @@ -152,9 +152,21 @@ const CompleteOAuthResponse = Schema.Struct({ · 151 unmodified lines | ||
| 152 | 152 | // Error |
| 153 | 153 | // --------------------------------------------------------------------------- |
| 154 | 154 | |
| 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 | +) {} | |
| 158 | 170 | |
| 159 | 171 | // --------------------------------------------------------------------------- |
| 160 | 172 | // Group |
| @@ -165,52 +177,61 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 4 unmodified lines | ||
| 165 | 177 | HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe` |
| 166 | 178 | .setPayload(ProbeEndpointPayload) |
| 167 | 179 | .addSuccess(ProbeEndpointResponse) |
| 168 | − .addError(McpApiError), | |
| 180 | + .addError(McpApiError) | |
| 181 | + .addError(McpInternalError), | |
| 169 | 182 | ) |
| 170 | 183 | .add( |
| 171 | 184 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources` |
| 172 | 185 | .setPayload(AddSourcePayload) |
| 173 | 186 | .addSuccess(AddSourceResponse) |
| 174 | − .addError(McpApiError), | |
| 187 | + .addError(McpApiError) | |
| 188 | + .addError(McpInternalError), | |
| 175 | 189 | ) |
| 176 | 190 | .add( |
| 177 | 191 | HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove` |
| 178 | 192 | .setPayload(NamespacePayload) |
| 179 | 193 | .addSuccess(RemoveSourceResponse) |
| 180 | − .addError(McpApiError), | |
| 194 | + .addError(McpApiError) | |
| 195 | + .addError(McpInternalError), | |
| 181 | 196 | ) |
| 182 | 197 | .add( |
| 183 | 198 | HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh` |
| 184 | 199 | .setPayload(NamespacePayload) |
| 185 | 200 | .addSuccess(RefreshSourceResponse) |
| 186 | − .addError(McpApiError), | |
| 201 | + .addError(McpApiError) | |
| 202 | + .addError(McpInternalError), | |
| 187 | 203 | ) |
| 188 | 204 | .add( |
| 189 | 205 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start` |
| 190 | 206 | .setPayload(StartOAuthPayload) |
| 191 | 207 | .addSuccess(StartOAuthResponse) |
| 192 | − .addError(McpApiError), | |
| 208 | + .addError(McpApiError) | |
| 209 | + .addError(McpInternalError), | |
| 193 | 210 | ) |
| 194 | 211 | .add( |
| 195 | 212 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete` |
| 196 | 213 | .setPayload(CompleteOAuthPayload) |
| 197 | 214 | .addSuccess(CompleteOAuthResponse) |
| 198 | − .addError(McpApiError), | |
| 215 | + .addError(McpApiError) | |
| 216 | + .addError(McpInternalError), | |
| 199 | 217 | ) |
| 200 | 218 | .add( |
| 201 | 219 | HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback` |
| 202 | 220 | .setUrlParams(OAuthCallbackParams) |
| 203 | 221 | .addSuccess(HtmlResponse) |
| 204 | − .addError(McpApiError), | |
| 222 | + .addError(McpApiError) | |
| 223 | + .addError(McpInternalError), | |
| 205 | 224 | ) |
| 206 | 225 | .add( |
| 207 | 226 | HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}` |
| 208 | 227 | .addSuccess(Schema.NullOr(McpStoredSourceSchema)) |
| 209 | − .addError(McpApiError), | |
| 228 | + .addError(McpApiError) | |
| 229 | + .addError(McpInternalError), | |
| 210 | 230 | ) |
| 211 | 231 | .add( |
| 212 | 232 | HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}` |
| 213 | 233 | .setPayload(UpdateSourcePayload) |
| 214 | 234 | .addSuccess(UpdateSourceResponse) |
| 215 | − .addError(McpApiError), | |
| 235 | + .addError(McpApiError) | |
| 236 | + .addError(McpInternalError), | |
| 216 | 237 | ) {} |
| @@ -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 | +}); | |
| @@ -1,9 +1,15 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | −import { Context, Effect } from "effect"; | |
| 2 | +import { Cause, Context, Effect } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { addGroup } from "@executor/api"; |
| 5 | 5 | 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"; | |
| 7 | 13 | |
| 8 | 14 | // --------------------------------------------------------------------------- |
| 9 | 15 | // Service tag |
| @@ -79,6 +85,33 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 69 unmodified lines | ||
| 79 | 85 | </body></html>`; |
| 80 | 86 | }; |
| 81 | 87 | |
| 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 | + | |
| 82 | 115 | // --------------------------------------------------------------------------- |
| 83 | 116 | // Convert API payload → McpSourceConfig |
| 84 | 117 | // --------------------------------------------------------------------------- |
| @@ -150,7 +183,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 65 unmodified lines | ||
| 150 | 183 | Effect.gen(function* () { |
| 151 | 184 | const ext = yield* McpExtensionService; |
| 152 | 185 | return yield* ext.probeEndpoint(payload.endpoint); |
| 153 | − }).pipe(Effect.orDie), | |
| 186 | + }).pipe(sanitizeMcpFailure), | |
| 154 | 187 | ) |
| 155 | 188 | .handle("addSource", ({ payload }) => |
| 156 | 189 | Effect.gen(function* () { |
| @@ -158,20 +191,20 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 1 unmodified line | ||
| 158 | 191 | return yield* ext.addSource( |
| 159 | 192 | toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]), |
| 160 | 193 | ); |
| 161 | − }).pipe(Effect.orDie), | |
| 194 | + }).pipe(sanitizeMcpFailure), | |
| 162 | 195 | ) |
| 163 | 196 | .handle("removeSource", ({ payload }) => |
| 164 | 197 | Effect.gen(function* () { |
| 165 | 198 | const ext = yield* McpExtensionService; |
| 166 | 199 | yield* ext.removeSource(payload.namespace); |
| 167 | 200 | return { removed: true }; |
| 168 | − }).pipe(Effect.orDie), | |
| 201 | + }).pipe(sanitizeMcpFailure), | |
| 169 | 202 | ) |
| 170 | 203 | .handle("refreshSource", ({ payload }) => |
| 171 | 204 | Effect.gen(function* () { |
| 172 | 205 | const ext = yield* McpExtensionService; |
| 173 | 206 | return yield* ext.refreshSource(payload.namespace); |
| 174 | − }).pipe(Effect.orDie), | |
| 207 | + }).pipe(sanitizeMcpFailure), | |
| 175 | 208 | ) |
| 176 | 209 | .handle("startOAuth", ({ payload }) => |
| 177 | 210 | Effect.gen(function* () { |
| @@ -181,7 +214,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines | ||
| 181 | 214 | redirectUrl: payload.redirectUrl, |
| 182 | 215 | queryParams: payload.queryParams, |
| 183 | 216 | }); |
| 184 | − }).pipe(Effect.orDie), | |
| 217 | + }).pipe(sanitizeMcpFailure), | |
| 185 | 218 | ) |
| 186 | 219 | .handle("completeOAuth", ({ payload }) => |
| 187 | 220 | Effect.gen(function* () { |
| @@ -191,13 +224,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines | ||
| 191 | 224 | code: payload.code, |
| 192 | 225 | error: payload.error, |
| 193 | 226 | }); |
| 194 | − }).pipe(Effect.orDie), | |
| 227 | + }).pipe(sanitizeMcpFailure), | |
| 195 | 228 | ) |
| 196 | 229 | .handle("getSource", ({ path }) => |
| 197 | 230 | Effect.gen(function* () { |
| 198 | 231 | const ext = yield* McpExtensionService; |
| 199 | 232 | return yield* ext.getSource(path.namespace); |
| 200 | − }).pipe(Effect.orDie), | |
| 233 | + }).pipe(sanitizeMcpFailure), | |
| 201 | 234 | ) |
| 202 | 235 | .handle("updateSource", ({ path, payload }) => |
| 203 | 236 | Effect.gen(function* () { |
| @@ -209,7 +242,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 5 unmodified lines | ||
| 209 | 242 | auth: payload.auth as McpUpdateSourceInput["auth"], |
| 210 | 243 | }); |
| 211 | 244 | return { updated: true }; |
| 212 | − }).pipe(Effect.orDie), | |
| 245 | + }).pipe(sanitizeMcpFailure), | |
| 213 | 246 | ) |
| 214 | 247 | .handle("oauthCallback", ({ urlParams }) => |
| 215 | 248 | Effect.gen(function* () { |
| @@ -233,16 +266,16 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 17 unmodified lines | ||
| 233 | 266 | scope: c.scope, |
| 234 | 267 | }), |
| 235 | 268 | ), |
| 236 | − Effect.catchAll((error) => | |
| 269 | + Effect.catchAllCause((cause) => | |
| 237 | 270 | Effect.succeed<OAuthPopupResult>({ |
| 238 | 271 | type: "executor:oauth-result", |
| 239 | 272 | ok: false, |
| 240 | 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 | 278 | return yield* HttpServerResponse.html(popupDocument(result)); |
| 246 | − }).pipe(Effect.orDie), | |
| 279 | + }).pipe(sanitizeMcpFailure), | |
| 247 | 280 | ), |
| 248 | 281 | ); |