Unified · Split
| @@ -11,20 +11,6 @@ concurrency: · 10 unmodified lines | |||
| 11 | cancel-in-progress: true | 11 | cancel-in-progress: true |
| 12 | 12 | ||
| 13 | jobs: | 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 | lint: | 14 | lint: |
| 29 | name: Lint | 15 | name: Lint |
| 30 | runs-on: ubuntu-latest | 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 | "$schema": "./node_modules/oxfmt/configuration_schema.json", | 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 | "devDependencies": { | 61 | "devDependencies": { |
| 62 | "@cloudflare/vitest-pool-workers": "^0.14.3", | 62 | "@cloudflare/vitest-pool-workers": "^0.14.3", |
| 63 | "@cloudflare/workers-types": "^4.20250620.0", | 63 | "@cloudflare/workers-types": "^4.20250620.0", |
| 64 | "@effect/platform-node": "catalog:", | ||
| 64 | "@effect/vitest": "^0.29.0", | 65 | "@effect/vitest": "^0.29.0", |
| 65 | "@electric-sql/pglite": "^0.4.3", | 66 | "@electric-sql/pglite": "^0.4.3", |
| 66 | "@electric-sql/pglite-socket": "^0.1.3", | 67 | "@electric-sql/pglite-socket": "^0.1.3", |
| @@ -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 | import { Effect, Layer } from "effect"; | 1 | import { Effect, Layer } from "effect"; |
| 15 | import { Autumn } from "autumn-js"; | 2 | import { AutumnApiApp } from "./api/autumn"; |
| 16 | import { autumnHandler } from "autumn-js/backend"; | 3 | import { NonProtectedApiApp, TeamApiApp } from "./api/layers"; |
| 17 | 4 | import { ProtectedApiApp } from "./api/protected"; | |
| 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"; | ||
| 42 | import { | 5 | import { |
| 43 | CloudAuthPublicHandlers, | 6 | ApiRequestHandler, |
| 44 | CloudSessionAuthHandlers, | 7 | AutumnRequestHandlerService, |
| 45 | NonProtectedApi, | 8 | NonProtectedRequestHandlerService, |
| 46 | } from "./auth/handlers"; | 9 | ProtectedRequestHandlerService, |
| 47 | import { WorkOSAuth } from "./auth/workos"; | 10 | TeamRequestHandlerService, |
| 48 | import { DbService } from "./services/db"; | 11 | } from "./api/router"; |
| 49 | import { createOrgExecutor } from "./services/executor"; | 12 | |
| 50 | import { TeamOrgApi } from "./team/compose"; | 13 | const ApiRequestHandlersLive = Layer.mergeAll( |
| 51 | import { TeamHandlers } from "./team/handlers"; | 14 | Layer.succeed(TeamRequestHandlerService, { app: TeamApiApp }), |
| 52 | import { TelemetryLive } from "./services/telemetry"; | 15 | Layer.succeed(NonProtectedRequestHandlerService, { app: NonProtectedApiApp }), |
| 53 | import { server } from "./env"; | 16 | Layer.succeed(AutumnRequestHandlerService, { app: AutumnApiApp }), |
| 54 | 17 | Layer.succeed(ProtectedRequestHandlerService, { app: ProtectedApiApp }), | |
| 55 | // --------------------------------------------------------------------------- | ||
| 56 | // API definitions | ||
| 57 | // --------------------------------------------------------------------------- | ||
| 58 | |||
| 59 | /** Protected (org-required) API — all the executor groups + OrgAuth middleware */ | ||
| 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), | ||
| 95 | ); | 18 | ); |
| 96 | 19 | ||
| 97 | const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe( | 20 | export const handleApiRequest = Effect.runSync( |
| 98 | Layer.provide(TeamHandlers), | 21 | Effect.provide(ApiRequestHandler, ApiRequestHandlersLive), |
| 99 | Layer.provideMerge(OrgAuthLive), | ||
| 100 | ); | 22 | ); |
| 101 | |||
| 102 | // --------------------------------------------------------------------------- | ||
| 103 | // Public auth web handler | ||
| 104 | // --------------------------------------------------------------------------- | ||
| 105 | // | ||
| 106 | // Build per-request, not once at module load. `toWebHandler` creates a | ||
| 107 | // single long-lived `Layer.MemoMap` that memoizes `DbService.Live`'s | ||
| 108 | // `Layer.scoped` acquire — the resulting `sql` connection is created in | ||
| 109 | // the module scope, not the request scope. Workerd tears down TCP sockets | ||
| 110 | // at request boundaries, so the second request on a cached handler fails | ||
| 111 | // with "Cannot perform I/O on behalf of a different request". Building a | ||
| 112 | // fresh handler per request gives each request its own layer scope and a | ||
| 113 | // fresh socket. Auth endpoints (login/callback/me/logout) are infrequent | ||
| 114 | // so the overhead is negligible. | ||
| 115 | // --------------------------------------------------------------------------- | ||
| 116 | |||
| 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 | setCookie("wos-session", sealedSession, COOKIE_OPTIONS); | 88 | setCookie("wos-session", sealedSession, COOKIE_OPTIONS); |
| 89 | return HttpServerResponse.redirect("/", { status: 302 }); | 89 | return HttpServerResponse.redirect("/", { status: 302 }); |
| 90 | }), | 90 | }).pipe( |
| 91 | Effect.catchTags({ | ||
| 92 | WorkOSError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })), | ||
| 93 | UserStoreError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })), | ||
| 94 | }), | ||
| 95 | ), | ||
| 91 | ), | 96 | ), |
| 92 | ); | 97 | ); |
| 93 | 98 | ||
| @@ -143,7 +143,7 @@ export class McpSessionDO extends DurableObject { · 142 unmodified lines | |||
| 143 | return await this.transport.handleRequest(request); | 143 | return await this.transport.handleRequest(request); |
| 144 | } catch (err) { | 144 | } catch (err) { |
| 145 | console.error("[mcp-session] handleRequest error:", err instanceof Error ? err.stack : err); | 145 | console.error("[mcp-session] handleRequest error:", err instanceof Error ? err.stack : err); |
| 146 | return jsonRpcError(500, -32603, err instanceof Error ? err.message : "Internal error"); | 146 | return jsonRpcError(500, -32603, "Internal error"); |
| 147 | } | 147 | } |
| 148 | } | 148 | } |
| 149 | 149 | ||
| @@ -128,7 +128,7 @@ const handleMcpRequest_POST = async (request: Request, token: VerifiedToken): Pr · 127 unmodified lines | |||
| 128 | return await stub.handleRequest(request); | 128 | return await stub.handleRequest(request); |
| 129 | } catch (err) { | 129 | } catch (err) { |
| 130 | console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err); | 130 | console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err); |
| 131 | return jsonRpcError(500, -32603, err instanceof Error ? err.message : "Internal server error"); | 131 | return jsonRpcError(500, -32603, "Internal server error"); |
| 132 | } | 132 | } |
| 133 | }; | 133 | }; |
| 134 | 134 | ||
| @@ -8,190 +8,190 @@ · 7 unmodified lines | |||
| 8 | // You should NOT make any changes in this file as it will be overwritten. | 8 | // You should NOT make any changes in this file as it will be overwritten. |
| 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. | 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. |
| 10 | 10 | ||
| 11 | import { Route as rootRouteImport } from "./routes/__root"; | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as ToolsRouteImport } from "./routes/tools"; | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | import { Route as TeamRouteImport } from "./routes/team"; | 13 | import { Route as TeamRouteImport } from './routes/team' |
| 14 | import { Route as SecretsRouteImport } from "./routes/secrets"; | 14 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 15 | import { Route as BillingRouteImport } from "./routes/billing"; | 15 | import { Route as BillingRouteImport } from './routes/billing' |
| 16 | import { Route as IndexRouteImport } from "./routes/index"; | 16 | import { Route as IndexRouteImport } from './routes/index' |
| 17 | import { Route as SourcesNamespaceRouteImport } from "./routes/sources.$namespace"; | 17 | import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' |
| 18 | import { Route as BillingPlansRouteImport } from "./routes/billing_.plans"; | 18 | import { Route as BillingPlansRouteImport } from './routes/billing_.plans' |
| 19 | import { Route as SourcesAddPluginKeyRouteImport } from "./routes/sources.add.$pluginKey"; | 19 | import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' |
| 20 | 20 | ||
| 21 | const ToolsRoute = ToolsRouteImport.update({ | 21 | const ToolsRoute = ToolsRouteImport.update({ |
| 22 | id: "/tools", | 22 | id: '/tools', |
| 23 | path: "/tools", | 23 | path: '/tools', |
| 24 | getParentRoute: () => rootRouteImport, | 24 | getParentRoute: () => rootRouteImport, |
| 25 | } as any); | 25 | } as any) |
| 26 | const TeamRoute = TeamRouteImport.update({ | 26 | const TeamRoute = TeamRouteImport.update({ |
| 27 | id: "/team", | 27 | id: '/team', |
| 28 | path: "/team", | 28 | path: '/team', |
| 29 | getParentRoute: () => rootRouteImport, | 29 | getParentRoute: () => rootRouteImport, |
| 30 | } as any); | 30 | } as any) |
| 31 | const SecretsRoute = SecretsRouteImport.update({ | 31 | const SecretsRoute = SecretsRouteImport.update({ |
| 32 | id: "/secrets", | 32 | id: '/secrets', |
| 33 | path: "/secrets", | 33 | path: '/secrets', |
| 34 | getParentRoute: () => rootRouteImport, | 34 | getParentRoute: () => rootRouteImport, |
| 35 | } as any); | 35 | } as any) |
| 36 | const BillingRoute = BillingRouteImport.update({ | 36 | const BillingRoute = BillingRouteImport.update({ |
| 37 | id: "/billing", | 37 | id: '/billing', |
| 38 | path: "/billing", | 38 | path: '/billing', |
| 39 | getParentRoute: () => rootRouteImport, | 39 | getParentRoute: () => rootRouteImport, |
| 40 | } as any); | 40 | } as any) |
| 41 | const IndexRoute = IndexRouteImport.update({ | 41 | const IndexRoute = IndexRouteImport.update({ |
| 42 | id: "/", | 42 | id: '/', |
| 43 | path: "/", | 43 | path: '/', |
| 44 | getParentRoute: () => rootRouteImport, | 44 | getParentRoute: () => rootRouteImport, |
| 45 | } as any); | 45 | } as any) |
| 46 | const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ | 46 | const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ |
| 47 | id: "/sources/$namespace", | 47 | id: '/sources/$namespace', |
| 48 | path: "/sources/$namespace", | 48 | path: '/sources/$namespace', |
| 49 | getParentRoute: () => rootRouteImport, | 49 | getParentRoute: () => rootRouteImport, |
| 50 | } as any); | 50 | } as any) |
| 51 | const BillingPlansRoute = BillingPlansRouteImport.update({ | 51 | const BillingPlansRoute = BillingPlansRouteImport.update({ |
| 52 | id: "/billing_/plans", | 52 | id: '/billing_/plans', |
| 53 | path: "/billing/plans", | 53 | path: '/billing/plans', |
| 54 | getParentRoute: () => rootRouteImport, | 54 | getParentRoute: () => rootRouteImport, |
| 55 | } as any); | 55 | } as any) |
| 56 | const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ | 56 | const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ |
| 57 | id: "/sources/add/$pluginKey", | 57 | id: '/sources/add/$pluginKey', |
| 58 | path: "/sources/add/$pluginKey", | 58 | path: '/sources/add/$pluginKey', |
| 59 | getParentRoute: () => rootRouteImport, | 59 | getParentRoute: () => rootRouteImport, |
| 60 | } as any); | 60 | } as any) |
| 61 | 61 | ||
| 62 | export interface FileRoutesByFullPath { | 62 | export interface FileRoutesByFullPath { |
| 63 | "/": typeof IndexRoute; | 63 | '/': typeof IndexRoute |
| 64 | "/billing": typeof BillingRoute; | 64 | '/billing': typeof BillingRoute |
| 65 | "/secrets": typeof SecretsRoute; | 65 | '/secrets': typeof SecretsRoute |
| 66 | "/team": typeof TeamRoute; | 66 | '/team': typeof TeamRoute |
| 67 | "/tools": typeof ToolsRoute; | 67 | '/tools': typeof ToolsRoute |
| 68 | "/billing/plans": typeof BillingPlansRoute; | 68 | '/billing/plans': typeof BillingPlansRoute |
| 69 | "/sources/$namespace": typeof SourcesNamespaceRoute; | 69 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 70 | "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; | 70 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 71 | } | 71 | } |
| 72 | export interface FileRoutesByTo { | 72 | export interface FileRoutesByTo { |
| 73 | "/": typeof IndexRoute; | 73 | '/': typeof IndexRoute |
| 74 | "/billing": typeof BillingRoute; | 74 | '/billing': typeof BillingRoute |
| 75 | "/secrets": typeof SecretsRoute; | 75 | '/secrets': typeof SecretsRoute |
| 76 | "/team": typeof TeamRoute; | 76 | '/team': typeof TeamRoute |
| 77 | "/tools": typeof ToolsRoute; | 77 | '/tools': typeof ToolsRoute |
| 78 | "/billing/plans": typeof BillingPlansRoute; | 78 | '/billing/plans': typeof BillingPlansRoute |
| 79 | "/sources/$namespace": typeof SourcesNamespaceRoute; | 79 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 80 | "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; | 80 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 81 | } | 81 | } |
| 82 | export interface FileRoutesById { | 82 | export interface FileRoutesById { |
| 83 | __root__: typeof rootRouteImport; | 83 | __root__: typeof rootRouteImport |
| 84 | "/": typeof IndexRoute; | 84 | '/': typeof IndexRoute |
| 85 | "/billing": typeof BillingRoute; | 85 | '/billing': typeof BillingRoute |
| 86 | "/secrets": typeof SecretsRoute; | 86 | '/secrets': typeof SecretsRoute |
| 87 | "/team": typeof TeamRoute; | 87 | '/team': typeof TeamRoute |
| 88 | "/tools": typeof ToolsRoute; | 88 | '/tools': typeof ToolsRoute |
| 89 | "/billing_/plans": typeof BillingPlansRoute; | 89 | '/billing_/plans': typeof BillingPlansRoute |
| 90 | "/sources/$namespace": typeof SourcesNamespaceRoute; | 90 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 91 | "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; | 91 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 92 | } | 92 | } |
| 93 | export interface FileRouteTypes { | 93 | export interface FileRouteTypes { |
| 94 | fileRoutesByFullPath: FileRoutesByFullPath; | 94 | fileRoutesByFullPath: FileRoutesByFullPath |
| 95 | fullPaths: | 95 | fullPaths: |
| 96 | | "/" | 96 | | '/' |
| 97 | | "/billing" | 97 | | '/billing' |
| 98 | | "/secrets" | 98 | | '/secrets' |
| 99 | | "/team" | 99 | | '/team' |
| 100 | | "/tools" | 100 | | '/tools' |
| 101 | | "/billing/plans" | 101 | | '/billing/plans' |
| 102 | | "/sources/$namespace" | 102 | | '/sources/$namespace' |
| 103 | | "/sources/add/$pluginKey"; | 103 | | '/sources/add/$pluginKey' |
| 104 | fileRoutesByTo: FileRoutesByTo; | 104 | fileRoutesByTo: FileRoutesByTo |
| 105 | to: | 105 | to: |
| 106 | | "/" | 106 | | '/' |
| 107 | | "/billing" | 107 | | '/billing' |
| 108 | | "/secrets" | 108 | | '/secrets' |
| 109 | | "/team" | 109 | | '/team' |
| 110 | | "/tools" | 110 | | '/tools' |
| 111 | | "/billing/plans" | 111 | | '/billing/plans' |
| 112 | | "/sources/$namespace" | 112 | | '/sources/$namespace' |
| 113 | | "/sources/add/$pluginKey"; | 113 | | '/sources/add/$pluginKey' |
| 114 | id: | 114 | id: |
| 115 | | "__root__" | 115 | | '__root__' |
| 116 | | "/" | 116 | | '/' |
| 117 | | "/billing" | 117 | | '/billing' |
| 118 | | "/secrets" | 118 | | '/secrets' |
| 119 | | "/team" | 119 | | '/team' |
| 120 | | "/tools" | 120 | | '/tools' |
| 121 | | "/billing_/plans" | 121 | | '/billing_/plans' |
| 122 | | "/sources/$namespace" | 122 | | '/sources/$namespace' |
| 123 | | "/sources/add/$pluginKey"; | 123 | | '/sources/add/$pluginKey' |
| 124 | fileRoutesById: FileRoutesById; | 124 | fileRoutesById: FileRoutesById |
| 125 | } | 125 | } |
| 126 | export interface RootRouteChildren { | 126 | export interface RootRouteChildren { |
| 127 | IndexRoute: typeof IndexRoute; | 127 | IndexRoute: typeof IndexRoute |
| 128 | BillingRoute: typeof BillingRoute; | 128 | BillingRoute: typeof BillingRoute |
| 129 | SecretsRoute: typeof SecretsRoute; | 129 | SecretsRoute: typeof SecretsRoute |
| 130 | TeamRoute: typeof TeamRoute; | 130 | TeamRoute: typeof TeamRoute |
| 131 | ToolsRoute: typeof ToolsRoute; | 131 | ToolsRoute: typeof ToolsRoute |
| 132 | BillingPlansRoute: typeof BillingPlansRoute; | 132 | BillingPlansRoute: typeof BillingPlansRoute |
| 133 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute; | 133 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute |
| 134 | SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute; | 134 | SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute |
| 135 | } | 135 | } |
| 136 | 136 | ||
| 137 | declare module "@tanstack/react-router" { | 137 | declare module '@tanstack/react-router' { |
| 138 | interface FileRoutesByPath { | 138 | interface FileRoutesByPath { |
| 139 | "/tools": { | 139 | '/tools': { |
| 140 | id: "/tools"; | 140 | id: '/tools' |
| 141 | path: "/tools"; | 141 | path: '/tools' |
| 142 | fullPath: "/tools"; | 142 | fullPath: '/tools' |
| 143 | preLoaderRoute: typeof ToolsRouteImport; | 143 | preLoaderRoute: typeof ToolsRouteImport |
| 144 | parentRoute: typeof rootRouteImport; | 144 | parentRoute: typeof rootRouteImport |
| 145 | }; | 145 | } |
| 146 | "/team": { | 146 | '/team': { |
| 147 | id: "/team"; | 147 | id: '/team' |
| 148 | path: "/team"; | 148 | path: '/team' |
| 149 | fullPath: "/team"; | 149 | fullPath: '/team' |
| 150 | preLoaderRoute: typeof TeamRouteImport; | 150 | preLoaderRoute: typeof TeamRouteImport |
| 151 | parentRoute: typeof rootRouteImport; | 151 | parentRoute: typeof rootRouteImport |
| 152 | }; | 152 | } |
| 153 | "/secrets": { | 153 | '/secrets': { |
| 154 | id: "/secrets"; | 154 | id: '/secrets' |
| 155 | path: "/secrets"; | 155 | path: '/secrets' |
| 156 | fullPath: "/secrets"; | 156 | fullPath: '/secrets' |
| 157 | preLoaderRoute: typeof SecretsRouteImport; | 157 | preLoaderRoute: typeof SecretsRouteImport |
| 158 | parentRoute: typeof rootRouteImport; | 158 | parentRoute: typeof rootRouteImport |
| 159 | }; | 159 | } |
| 160 | "/billing": { | 160 | '/billing': { |
| 161 | id: "/billing"; | 161 | id: '/billing' |
| 162 | path: "/billing"; | 162 | path: '/billing' |
| 163 | fullPath: "/billing"; | 163 | fullPath: '/billing' |
| 164 | preLoaderRoute: typeof BillingRouteImport; | 164 | preLoaderRoute: typeof BillingRouteImport |
| 165 | parentRoute: typeof rootRouteImport; | 165 | parentRoute: typeof rootRouteImport |
| 166 | }; | 166 | } |
| 167 | "/": { | 167 | '/': { |
| 168 | id: "/"; | 168 | id: '/' |
| 169 | path: "/"; | 169 | path: '/' |
| 170 | fullPath: "/"; | 170 | fullPath: '/' |
| 171 | preLoaderRoute: typeof IndexRouteImport; | 171 | preLoaderRoute: typeof IndexRouteImport |
| 172 | parentRoute: typeof rootRouteImport; | 172 | parentRoute: typeof rootRouteImport |
| 173 | }; | 173 | } |
| 174 | "/sources/$namespace": { | 174 | '/sources/$namespace': { |
| 175 | id: "/sources/$namespace"; | 175 | id: '/sources/$namespace' |
| 176 | path: "/sources/$namespace"; | 176 | path: '/sources/$namespace' |
| 177 | fullPath: "/sources/$namespace"; | 177 | fullPath: '/sources/$namespace' |
| 178 | preLoaderRoute: typeof SourcesNamespaceRouteImport; | 178 | preLoaderRoute: typeof SourcesNamespaceRouteImport |
| 179 | parentRoute: typeof rootRouteImport; | 179 | parentRoute: typeof rootRouteImport |
| 180 | }; | 180 | } |
| 181 | "/billing_/plans": { | 181 | '/billing_/plans': { |
| 182 | id: "/billing_/plans"; | 182 | id: '/billing_/plans' |
| 183 | path: "/billing/plans"; | 183 | path: '/billing/plans' |
| 184 | fullPath: "/billing/plans"; | 184 | fullPath: '/billing/plans' |
| 185 | preLoaderRoute: typeof BillingPlansRouteImport; | 185 | preLoaderRoute: typeof BillingPlansRouteImport |
| 186 | parentRoute: typeof rootRouteImport; | 186 | parentRoute: typeof rootRouteImport |
| 187 | }; | 187 | } |
| 188 | "/sources/add/$pluginKey": { | 188 | '/sources/add/$pluginKey': { |
| 189 | id: "/sources/add/$pluginKey"; | 189 | id: '/sources/add/$pluginKey' |
| 190 | path: "/sources/add/$pluginKey"; | 190 | path: '/sources/add/$pluginKey' |
| 191 | fullPath: "/sources/add/$pluginKey"; | 191 | fullPath: '/sources/add/$pluginKey' |
| 192 | preLoaderRoute: typeof SourcesAddPluginKeyRouteImport; | 192 | preLoaderRoute: typeof SourcesAddPluginKeyRouteImport |
| 193 | parentRoute: typeof rootRouteImport; | 193 | parentRoute: typeof rootRouteImport |
| 194 | }; | 194 | } |
| 195 | } | 195 | } |
| 196 | } | 196 | } |
| 197 | 197 | ||
| @@ -204,17 +204,17 @@ const rootRouteChildren: RootRouteChildren = { · 6 unmodified lines | |||
| 204 | BillingPlansRoute: BillingPlansRoute, | 204 | BillingPlansRoute: BillingPlansRoute, |
| 205 | SourcesNamespaceRoute: SourcesNamespaceRoute, | 205 | SourcesNamespaceRoute: SourcesNamespaceRoute, |
| 206 | SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, | 206 | SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, |
| 207 | }; | 207 | } |
| 208 | export const routeTree = rootRouteImport | 208 | export const routeTree = rootRouteImport |
| 209 | ._addFileChildren(rootRouteChildren) | 209 | ._addFileChildren(rootRouteChildren) |
| 210 | ._addFileTypes<FileRouteTypes>(); | 210 | ._addFileTypes<FileRouteTypes>() |
| 211 | 211 | ||
| 212 | import type { getRouter } from "./router.tsx"; | 212 | import type { getRouter } from './router.tsx' |
| 213 | import type { startInstance } from "./start.ts"; | 213 | import type { startInstance } from './start.ts' |
| 214 | declare module "@tanstack/react-start" { | 214 | declare module '@tanstack/react-start' { |
| 215 | interface Register { | 215 | interface Register { |
| 216 | ssr: true; | 216 | ssr: true |
| 217 | router: Awaited<ReturnType<typeof getRouter>>; | 217 | router: Awaited<ReturnType<typeof getRouter>> |
| 218 | config: Awaited<ReturnType<typeof startInstance.getOptions>>; | 218 | config: Awaited<ReturnType<typeof startInstance.getOptions>> |
| 219 | } | 219 | } |
| 220 | } | 220 | } |
| @@ -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 | return response; | 70 | return response; |
| 71 | } catch (error) { | 71 | } catch (error) { |
| 72 | console.error("[mcp] handleRequest error:", error instanceof Error ? error.stack : error); | ||
| 72 | if (!transport.sessionId) { | 73 | if (!transport.sessionId) { |
| 73 | await transport.close().catch(() => undefined); | 74 | await transport.close().catch(() => undefined); |
| 74 | await created?.close().catch(() => undefined); | 75 | await created?.close().catch(() => undefined); |
| 75 | } | 76 | } |
| 76 | return jsonError( | 77 | return jsonError(500, -32603, "Internal server error"); |
| 77 | 500, | ||
| 78 | -32603, | ||
| 79 | error instanceof Error ? error.message : "Internal server error", | ||
| 80 | ); | ||
| 81 | } | 78 | } |
| 82 | }, | 79 | }, |
| 83 | 80 | ||
| @@ -87,6 +87,7 @@ · 86 unmodified lines | |||
| 87 | "devDependencies": { | 87 | "devDependencies": { |
| 88 | "@cloudflare/vitest-pool-workers": "^0.14.3", | 88 | "@cloudflare/vitest-pool-workers": "^0.14.3", |
| 89 | "@cloudflare/workers-types": "^4.20250620.0", | 89 | "@cloudflare/workers-types": "^4.20250620.0", |
| 90 | "@effect/platform-node": "catalog:", | ||
| 90 | "@effect/vitest": "^0.29.0", | 91 | "@effect/vitest": "^0.29.0", |
| 91 | "@electric-sql/pglite": "^0.4.3", | 92 | "@electric-sql/pglite": "^0.4.3", |
| 92 | "@electric-sql/pglite-socket": "^0.1.3", | 93 | "@electric-sql/pglite-socket": "^0.1.3", |
| @@ -3867,7 +3868,7 @@ · 3774 unmodified lines | |||
| 3867 | 3868 | ||
| 3868 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], | 3869 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], |
| 3869 | 3870 | ||
| 3870 | "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], | 3871 | "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], |
| 3871 | 3872 | ||
| 3872 | "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], | 3873 | "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], |
| 3873 | 3874 | ||
| @@ -3921,14 +3922,12 @@ · 47 unmodified lines | |||
| 3921 | 3922 | ||
| 3922 | "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], | 3923 | "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], |
| 3923 | 3924 | ||
| 3925 | "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], | ||
| 3926 | |||
| 3924 | "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], | 3927 | "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| 3925 | 3928 | ||
| 3926 | "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], | 3929 | "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], |
| 3927 | 3930 | ||
| 3928 | "@effect/platform-node/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], | ||
| 3929 | |||
| 3930 | "@effect/platform-node-shared/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], | ||
| 3931 | |||
| 3932 | "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], | 3931 | "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], |
| 3933 | 3932 | ||
| 3934 | "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], | 3933 | "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], |
| @@ -4229,6 +4228,8 @@ · 294 unmodified lines | |||
| 4229 | 4228 | ||
| 4230 | "miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], | 4229 | "miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], |
| 4231 | 4230 | ||
| 4231 | "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], | ||
| 4232 | |||
| 4232 | "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | 4233 | "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], |
| 4233 | 4234 | ||
| 4234 | "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], | 4235 | "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], |
| @@ -93,45 +93,63 @@ const OAuthCallbackParams = Schema.Struct({ · 92 unmodified lines | |||
| 93 | 93 | ||
| 94 | const HtmlResponse = HttpApiSchema.Text({ contentType: "text/html" }); | 94 | const HtmlResponse = HttpApiSchema.Text({ contentType: "text/html" }); |
| 95 | 95 | ||
| 96 | const ApiError = Schema.Struct({ | 96 | export class GoogleDiscoveryApiError extends Schema.TaggedError<GoogleDiscoveryApiError>()( |
| 97 | message: Schema.String, | 97 | "GoogleDiscoveryApiError", |
| 98 | }).annotations(HttpApiSchema.annotations({ status: 400 })); | 98 | { |
| 99 | message: Schema.String, | ||
| 100 | }, | ||
| 101 | HttpApiSchema.annotations({ status: 400 }), | ||
| 102 | ) {} | ||
| 103 | |||
| 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 | export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") | 112 | export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") |
| 101 | .add( | 113 | .add( |
| 102 | HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe` | 114 | HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe` |
| 103 | .setPayload(ProbePayload) | 115 | .setPayload(ProbePayload) |
| 104 | .addSuccess(ProbeResponse) | 116 | .addSuccess(ProbeResponse) |
| 105 | .addError(ApiError), | 117 | .addError(GoogleDiscoveryApiError) |
| 118 | .addError(GoogleDiscoveryInternalError), | ||
| 106 | ) | 119 | ) |
| 107 | .add( | 120 | .add( |
| 108 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources` | 121 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources` |
| 109 | .setPayload(AddSourcePayload) | 122 | .setPayload(AddSourcePayload) |
| 110 | .addSuccess(AddSourceResponse) | 123 | .addSuccess(AddSourceResponse) |
| 111 | .addError(ApiError), | 124 | .addError(GoogleDiscoveryApiError) |
| 125 | .addError(GoogleDiscoveryInternalError), | ||
| 112 | ) | 126 | ) |
| 113 | .add( | 127 | .add( |
| 114 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start` | 128 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start` |
| 115 | .setPayload(StartOAuthPayload) | 129 | .setPayload(StartOAuthPayload) |
| 116 | .addSuccess(StartOAuthResponse) | 130 | .addSuccess(StartOAuthResponse) |
| 117 | .addError(ApiError), | 131 | .addError(GoogleDiscoveryApiError) |
| 132 | .addError(GoogleDiscoveryInternalError), | ||
| 118 | ) | 133 | ) |
| 119 | .add( | 134 | .add( |
| 120 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete` | 135 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete` |
| 121 | .setPayload(CompleteOAuthPayload) | 136 | .setPayload(CompleteOAuthPayload) |
| 122 | .addSuccess(CompleteOAuthResponse) | 137 | .addSuccess(CompleteOAuthResponse) |
| 123 | .addError(ApiError), | 138 | .addError(GoogleDiscoveryApiError) |
| 139 | .addError(GoogleDiscoveryInternalError), | ||
| 124 | ) | 140 | ) |
| 125 | .add( | 141 | .add( |
| 126 | HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback` | 142 | HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback` |
| 127 | .setUrlParams(OAuthCallbackParams) | 143 | .setUrlParams(OAuthCallbackParams) |
| 128 | .addSuccess(HtmlResponse) | 144 | .addSuccess(HtmlResponse) |
| 129 | .addError(ApiError), | 145 | .addError(GoogleDiscoveryApiError) |
| 146 | .addError(GoogleDiscoveryInternalError), | ||
| 130 | ) | 147 | ) |
| 131 | .add( | 148 | .add( |
| 132 | HttpApiEndpoint.get( | 149 | HttpApiEndpoint.get( |
| 133 | "getSource", | 150 | "getSource", |
| 134 | )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}` | 151 | )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}` |
| 135 | .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)) | 152 | .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)) |
| 136 | .addError(ApiError), | 153 | .addError(GoogleDiscoveryApiError) |
| 154 | .addError(GoogleDiscoveryInternalError), | ||
| 137 | ) {} | 155 | ) {} |
| @@ -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 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | import { Context, Effect } from "effect"; | 2 | import { Cause, Context, Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { addGroup } from "@executor/api"; | 4 | import { addGroup } from "@executor/api"; |
| 5 | import type { | 5 | import type { |
| @@ -7,7 +7,17 @@ import type { · 1 unmodified line | |||
| 7 | GoogleDiscoveryOAuthAuthResult, | 7 | GoogleDiscoveryOAuthAuthResult, |
| 8 | GoogleDiscoveryPluginExtension, | 8 | GoogleDiscoveryPluginExtension, |
| 9 | } from "../sdk/plugin"; | 9 | } from "../sdk/plugin"; |
| 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 | export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")< | 22 | export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")< |
| 13 | GoogleDiscoveryExtensionService, | 23 | GoogleDiscoveryExtensionService, |
| @@ -66,6 +76,37 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 52 unmodified lines | |||
| 66 | </body></html>`; | 76 | </body></html>`; |
| 67 | }; | 77 | }; |
| 68 | 78 | ||
| 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 | export const GoogleDiscoveryHandlers = HttpApiBuilder.group( | 110 | export const GoogleDiscoveryHandlers = HttpApiBuilder.group( |
| 70 | ExecutorApiWithGoogleDiscovery, | 111 | ExecutorApiWithGoogleDiscovery, |
| 71 | "googleDiscovery", | 112 | "googleDiscovery", |
| @@ -75,13 +116,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines | |||
| 75 | Effect.gen(function* () { | 116 | Effect.gen(function* () { |
| 76 | const ext = yield* GoogleDiscoveryExtensionService; | 117 | const ext = yield* GoogleDiscoveryExtensionService; |
| 77 | return yield* ext.probeDiscovery(payload.discoveryUrl); | 118 | return yield* ext.probeDiscovery(payload.discoveryUrl); |
| 78 | }).pipe(Effect.orDie), | 119 | }).pipe(sanitizeGoogleDiscoveryFailure), |
| 79 | ) | 120 | ) |
| 80 | .handle("addSource", ({ payload }) => | 121 | .handle("addSource", ({ payload }) => |
| 81 | Effect.gen(function* () { | 122 | Effect.gen(function* () { |
| 82 | const ext = yield* GoogleDiscoveryExtensionService; | 123 | const ext = yield* GoogleDiscoveryExtensionService; |
| 83 | return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput); | 124 | return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput); |
| 84 | }).pipe(Effect.orDie), | 125 | }).pipe(sanitizeGoogleDiscoveryFailure), |
| 85 | ) | 126 | ) |
| 86 | .handle("startOAuth", ({ payload }) => | 127 | .handle("startOAuth", ({ payload }) => |
| 87 | Effect.gen(function* () { | 128 | Effect.gen(function* () { |
| @@ -94,7 +135,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 6 unmodified lines | |||
| 94 | redirectUrl: payload.redirectUrl, | 135 | redirectUrl: payload.redirectUrl, |
| 95 | scopes: payload.scopes, | 136 | scopes: payload.scopes, |
| 96 | }); | 137 | }); |
| 97 | }).pipe(Effect.orDie), | 138 | }).pipe(sanitizeGoogleDiscoveryFailure), |
| 98 | ) | 139 | ) |
| 99 | .handle("completeOAuth", ({ payload }) => | 140 | .handle("completeOAuth", ({ payload }) => |
| 100 | Effect.gen(function* () { | 141 | Effect.gen(function* () { |
| @@ -104,13 +145,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines | |||
| 104 | code: payload.code, | 145 | code: payload.code, |
| 105 | error: payload.error, | 146 | error: payload.error, |
| 106 | }); | 147 | }); |
| 107 | }).pipe(Effect.orDie), | 148 | }).pipe(sanitizeGoogleDiscoveryFailure), |
| 108 | ) | 149 | ) |
| 109 | .handle("getSource", ({ path }) => | 150 | .handle("getSource", ({ path }) => |
| 110 | Effect.gen(function* () { | 151 | Effect.gen(function* () { |
| 111 | const ext = yield* GoogleDiscoveryExtensionService; | 152 | const ext = yield* GoogleDiscoveryExtensionService; |
| 112 | return yield* ext.getSource(path.namespace); | 153 | return yield* ext.getSource(path.namespace); |
| 113 | }).pipe(Effect.orDie), | 154 | }).pipe(sanitizeGoogleDiscoveryFailure), |
| 114 | ) | 155 | ) |
| 115 | .handle("oauthCallback", ({ urlParams }) => | 156 | .handle("oauthCallback", ({ urlParams }) => |
| 116 | Effect.gen(function* () { | 157 | Effect.gen(function* () { |
| @@ -130,16 +171,16 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 13 unmodified lines | |||
| 130 | ...auth, | 171 | ...auth, |
| 131 | }), | 172 | }), |
| 132 | ), | 173 | ), |
| 133 | Effect.catchAll((error) => | 174 | Effect.catchAllCause((cause) => |
| 134 | Effect.succeed<OAuthPopupResult>({ | 175 | Effect.succeed<OAuthPopupResult>({ |
| 135 | type: "executor:oauth-result", | 176 | type: "executor:oauth-result", |
| 136 | ok: false, | 177 | ok: false, |
| 137 | sessionId: null, | 178 | sessionId: null, |
| 138 | error: error instanceof Error ? error.message : String(error), | 179 | error: toPopupErrorMessage(Cause.squash(cause)), |
| 139 | }), | 180 | }), |
| 140 | ), | 181 | ), |
| 141 | ); | 182 | ); |
| 142 | return yield* HttpServerResponse.html(popupDocument(result)); | 183 | return yield* HttpServerResponse.html(popupDocument(result)); |
| 143 | }).pipe(Effect.orDie), | 184 | }).pipe(sanitizeGoogleDiscoveryFailure), |
| 144 | ), | 185 | ), |
| 145 | ); | 186 | ); |
| @@ -152,9 +152,21 @@ const CompleteOAuthResponse = Schema.Struct({ · 151 unmodified lines | |||
| 152 | // Error | 152 | // Error |
| 153 | // --------------------------------------------------------------------------- | 153 | // --------------------------------------------------------------------------- |
| 154 | 154 | ||
| 155 | const McpApiError = Schema.Struct({ | 155 | export class McpApiError extends Schema.TaggedError<McpApiError>()( |
| 156 | message: Schema.String, | 156 | "McpApiError", |
| 157 | }).annotations(HttpApiSchema.annotations({ status: 400 })); | 157 | { |
| 158 | message: Schema.String, | ||
| 159 | }, | ||
| 160 | HttpApiSchema.annotations({ status: 400 }), | ||
| 161 | ) {} | ||
| 162 | |||
| 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 | // Group | 172 | // Group |
| @@ -165,52 +177,61 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 4 unmodified lines | |||
| 165 | HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe` | 177 | HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe` |
| 166 | .setPayload(ProbeEndpointPayload) | 178 | .setPayload(ProbeEndpointPayload) |
| 167 | .addSuccess(ProbeEndpointResponse) | 179 | .addSuccess(ProbeEndpointResponse) |
| 168 | .addError(McpApiError), | 180 | .addError(McpApiError) |
| 181 | .addError(McpInternalError), | ||
| 169 | ) | 182 | ) |
| 170 | .add( | 183 | .add( |
| 171 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources` | 184 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources` |
| 172 | .setPayload(AddSourcePayload) | 185 | .setPayload(AddSourcePayload) |
| 173 | .addSuccess(AddSourceResponse) | 186 | .addSuccess(AddSourceResponse) |
| 174 | .addError(McpApiError), | 187 | .addError(McpApiError) |
| 188 | .addError(McpInternalError), | ||
| 175 | ) | 189 | ) |
| 176 | .add( | 190 | .add( |
| 177 | HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove` | 191 | HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove` |
| 178 | .setPayload(NamespacePayload) | 192 | .setPayload(NamespacePayload) |
| 179 | .addSuccess(RemoveSourceResponse) | 193 | .addSuccess(RemoveSourceResponse) |
| 180 | .addError(McpApiError), | 194 | .addError(McpApiError) |
| 195 | .addError(McpInternalError), | ||
| 181 | ) | 196 | ) |
| 182 | .add( | 197 | .add( |
| 183 | HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh` | 198 | HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh` |
| 184 | .setPayload(NamespacePayload) | 199 | .setPayload(NamespacePayload) |
| 185 | .addSuccess(RefreshSourceResponse) | 200 | .addSuccess(RefreshSourceResponse) |
| 186 | .addError(McpApiError), | 201 | .addError(McpApiError) |
| 202 | .addError(McpInternalError), | ||
| 187 | ) | 203 | ) |
| 188 | .add( | 204 | .add( |
| 189 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start` | 205 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start` |
| 190 | .setPayload(StartOAuthPayload) | 206 | .setPayload(StartOAuthPayload) |
| 191 | .addSuccess(StartOAuthResponse) | 207 | .addSuccess(StartOAuthResponse) |
| 192 | .addError(McpApiError), | 208 | .addError(McpApiError) |
| 209 | .addError(McpInternalError), | ||
| 193 | ) | 210 | ) |
| 194 | .add( | 211 | .add( |
| 195 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete` | 212 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete` |
| 196 | .setPayload(CompleteOAuthPayload) | 213 | .setPayload(CompleteOAuthPayload) |
| 197 | .addSuccess(CompleteOAuthResponse) | 214 | .addSuccess(CompleteOAuthResponse) |
| 198 | .addError(McpApiError), | 215 | .addError(McpApiError) |
| 216 | .addError(McpInternalError), | ||
| 199 | ) | 217 | ) |
| 200 | .add( | 218 | .add( |
| 201 | HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback` | 219 | HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback` |
| 202 | .setUrlParams(OAuthCallbackParams) | 220 | .setUrlParams(OAuthCallbackParams) |
| 203 | .addSuccess(HtmlResponse) | 221 | .addSuccess(HtmlResponse) |
| 204 | .addError(McpApiError), | 222 | .addError(McpApiError) |
| 223 | .addError(McpInternalError), | ||
| 205 | ) | 224 | ) |
| 206 | .add( | 225 | .add( |
| 207 | HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}` | 226 | HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}` |
| 208 | .addSuccess(Schema.NullOr(McpStoredSourceSchema)) | 227 | .addSuccess(Schema.NullOr(McpStoredSourceSchema)) |
| 209 | .addError(McpApiError), | 228 | .addError(McpApiError) |
| 229 | .addError(McpInternalError), | ||
| 210 | ) | 230 | ) |
| 211 | .add( | 231 | .add( |
| 212 | HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}` | 232 | HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}` |
| 213 | .setPayload(UpdateSourcePayload) | 233 | .setPayload(UpdateSourcePayload) |
| 214 | .addSuccess(UpdateSourceResponse) | 234 | .addSuccess(UpdateSourceResponse) |
| 215 | .addError(McpApiError), | 235 | .addError(McpApiError) |
| 236 | .addError(McpInternalError), | ||
| 216 | ) {} | 237 | ) {} |
| @@ -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 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | import { Context, Effect } from "effect"; | 2 | import { Cause, Context, Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { addGroup } from "@executor/api"; | 4 | import { addGroup } from "@executor/api"; |
| 5 | import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin"; | 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 | // Service tag | 15 | // Service tag |
| @@ -79,6 +85,33 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 69 unmodified lines | |||
| 79 | </body></html>`; | 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 | // Convert API payload → McpSourceConfig | 116 | // Convert API payload → McpSourceConfig |
| 84 | // --------------------------------------------------------------------------- | 117 | // --------------------------------------------------------------------------- |
| @@ -150,7 +183,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 65 unmodified lines | |||
| 150 | Effect.gen(function* () { | 183 | Effect.gen(function* () { |
| 151 | const ext = yield* McpExtensionService; | 184 | const ext = yield* McpExtensionService; |
| 152 | return yield* ext.probeEndpoint(payload.endpoint); | 185 | return yield* ext.probeEndpoint(payload.endpoint); |
| 153 | }).pipe(Effect.orDie), | 186 | }).pipe(sanitizeMcpFailure), |
| 154 | ) | 187 | ) |
| 155 | .handle("addSource", ({ payload }) => | 188 | .handle("addSource", ({ payload }) => |
| 156 | Effect.gen(function* () { | 189 | Effect.gen(function* () { |
| @@ -158,20 +191,20 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 1 unmodified line | |||
| 158 | return yield* ext.addSource( | 191 | return yield* ext.addSource( |
| 159 | toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]), | 192 | toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]), |
| 160 | ); | 193 | ); |
| 161 | }).pipe(Effect.orDie), | 194 | }).pipe(sanitizeMcpFailure), |
| 162 | ) | 195 | ) |
| 163 | .handle("removeSource", ({ payload }) => | 196 | .handle("removeSource", ({ payload }) => |
| 164 | Effect.gen(function* () { | 197 | Effect.gen(function* () { |
| 165 | const ext = yield* McpExtensionService; | 198 | const ext = yield* McpExtensionService; |
| 166 | yield* ext.removeSource(payload.namespace); | 199 | yield* ext.removeSource(payload.namespace); |
| 167 | return { removed: true }; | 200 | return { removed: true }; |
| 168 | }).pipe(Effect.orDie), | 201 | }).pipe(sanitizeMcpFailure), |
| 169 | ) | 202 | ) |
| 170 | .handle("refreshSource", ({ payload }) => | 203 | .handle("refreshSource", ({ payload }) => |
| 171 | Effect.gen(function* () { | 204 | Effect.gen(function* () { |
| 172 | const ext = yield* McpExtensionService; | 205 | const ext = yield* McpExtensionService; |
| 173 | return yield* ext.refreshSource(payload.namespace); | 206 | return yield* ext.refreshSource(payload.namespace); |
| 174 | }).pipe(Effect.orDie), | 207 | }).pipe(sanitizeMcpFailure), |
| 175 | ) | 208 | ) |
| 176 | .handle("startOAuth", ({ payload }) => | 209 | .handle("startOAuth", ({ payload }) => |
| 177 | Effect.gen(function* () { | 210 | Effect.gen(function* () { |
| @@ -181,7 +214,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines | |||
| 181 | redirectUrl: payload.redirectUrl, | 214 | redirectUrl: payload.redirectUrl, |
| 182 | queryParams: payload.queryParams, | 215 | queryParams: payload.queryParams, |
| 183 | }); | 216 | }); |
| 184 | }).pipe(Effect.orDie), | 217 | }).pipe(sanitizeMcpFailure), |
| 185 | ) | 218 | ) |
| 186 | .handle("completeOAuth", ({ payload }) => | 219 | .handle("completeOAuth", ({ payload }) => |
| 187 | Effect.gen(function* () { | 220 | Effect.gen(function* () { |
| @@ -191,13 +224,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines | |||
| 191 | code: payload.code, | 224 | code: payload.code, |
| 192 | error: payload.error, | 225 | error: payload.error, |
| 193 | }); | 226 | }); |
| 194 | }).pipe(Effect.orDie), | 227 | }).pipe(sanitizeMcpFailure), |
| 195 | ) | 228 | ) |
| 196 | .handle("getSource", ({ path }) => | 229 | .handle("getSource", ({ path }) => |
| 197 | Effect.gen(function* () { | 230 | Effect.gen(function* () { |
| 198 | const ext = yield* McpExtensionService; | 231 | const ext = yield* McpExtensionService; |
| 199 | return yield* ext.getSource(path.namespace); | 232 | return yield* ext.getSource(path.namespace); |
| 200 | }).pipe(Effect.orDie), | 233 | }).pipe(sanitizeMcpFailure), |
| 201 | ) | 234 | ) |
| 202 | .handle("updateSource", ({ path, payload }) => | 235 | .handle("updateSource", ({ path, payload }) => |
| 203 | Effect.gen(function* () { | 236 | Effect.gen(function* () { |
| @@ -209,7 +242,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 5 unmodified lines | |||
| 209 | auth: payload.auth as McpUpdateSourceInput["auth"], | 242 | auth: payload.auth as McpUpdateSourceInput["auth"], |
| 210 | }); | 243 | }); |
| 211 | return { updated: true }; | 244 | return { updated: true }; |
| 212 | }).pipe(Effect.orDie), | 245 | }).pipe(sanitizeMcpFailure), |
| 213 | ) | 246 | ) |
| 214 | .handle("oauthCallback", ({ urlParams }) => | 247 | .handle("oauthCallback", ({ urlParams }) => |
| 215 | Effect.gen(function* () { | 248 | Effect.gen(function* () { |
| @@ -233,16 +266,16 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 17 unmodified lines | |||
| 233 | scope: c.scope, | 266 | scope: c.scope, |
| 234 | }), | 267 | }), |
| 235 | ), | 268 | ), |
| 236 | Effect.catchAll((error) => | 269 | Effect.catchAllCause((cause) => |
| 237 | Effect.succeed<OAuthPopupResult>({ | 270 | Effect.succeed<OAuthPopupResult>({ |
| 238 | type: "executor:oauth-result", | 271 | type: "executor:oauth-result", |
| 239 | ok: false, | 272 | ok: false, |
| 240 | sessionId: null, | 273 | sessionId: null, |
| 241 | error: error instanceof Error ? error.message : String(error), | 274 | error: toPopupErrorMessage(Cause.squash(cause)), |
| 242 | }), | 275 | }), |
| 243 | ), | 276 | ), |
| 244 | ); | 277 | ); |
| 245 | return yield* HttpServerResponse.html(popupDocument(result)); | 278 | return yield* HttpServerResponse.html(popupDocument(result)); |
| 246 | }).pipe(Effect.orDie), | 279 | }).pipe(sanitizeMcpFailure), |
| 247 | ), | 280 | ), |
| 248 | ); | 281 | ); |