The post-v4 routing was a 4-Service-wrapper + asRouteApp(toWebHandler/fromWebHandler) round-trip mounting opaque sub-apps under a parent HttpRouter. Collapse it to a single HttpRouter where each sub-API contributes its routes via HttpApiBuilder.layer (which already calls HttpRouter.use under the hood) and autumn's catch-all proxy is a plain HttpRouter.add. Per-request executor stack construction moves to a single HttpRouter middleware that authenticates, verifies live org membership, and provides AuthContext + executor services in one place — replacing both the duplicate outer WorkOS lookup in protected.ts and the OrgAuth HttpApiMiddleware on ProtectedCloudApi. HttpResponseError now implements Respondable so typed errors render as JSON via the framework's cause→response pipeline, eliminating the need for catchCause-to-response wrappers and the type casts they required.
Unified · Split
| @@ -1,33 +1,35 @@ | ||
| 1 | −import { HttpApi, HttpApiBuilder, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | |
| 2 | −import { FetchHttpClient, HttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 1 | +import { | |
| 2 | + HttpApi, | |
| 3 | + HttpApiBuilder, | |
| 4 | + HttpApiClient, | |
| 5 | + HttpApiEndpoint, | |
| 6 | + HttpApiGroup, | |
| 7 | +} from "effect/unstable/httpapi"; | |
| 8 | +import { | |
| 9 | + FetchHttpClient, | |
| 10 | + HttpClient, | |
| 11 | + HttpRouter, | |
| 12 | + HttpServer, | |
| 13 | + HttpServerResponse, | |
| 14 | +} from "effect/unstable/http"; | |
| 3 | 15 | import { expect, layer } from "@effect/vitest"; |
| 4 | 16 | import { Effect, Layer, Schema } from "effect"; |
| 5 | 17 | |
| 6 | −import { | |
| 7 | − ApiRequestHandler, | |
| 8 | − AutumnRequestHandlerService, | |
| 9 | − NonProtectedRequestHandlerService, | |
| 10 | − ProtectedRequestHandlerService, | |
| 11 | − OrgRequestHandlerService, | |
| 12 | −} from "./api/router"; | |
| 13 | − | |
| 14 | 18 | const SourceResponse = Schema.Struct({ source: Schema.String }); |
| 15 | 19 | |
| 20 | +// --------------------------------------------------------------------------- | |
| 21 | +// Test APIs — mirror the prod paths but with stub handlers. | |
| 22 | +// --------------------------------------------------------------------------- | |
| 23 | + | |
| 16 | 24 | const OrgGroup = HttpApiGroup.make("org").add( |
| 17 | 25 | HttpApiEndpoint.get("ping", "/org/ping", { success: SourceResponse }), |
| 18 | 26 | ); |
| 19 | 27 | const OrgTestApi = HttpApi.make("orgApi").add(OrgGroup); |
| 20 | −const OrgTestHandlers = HttpApiBuilder.group(OrgTestApi, "org", (handlers) => | |
| 21 | − handlers.handle("ping", () => Effect.succeed({ source: "org" })), | |
| 22 | −); | |
| 23 | 28 | |
| 24 | 29 | const AuthGroup = HttpApiGroup.make("auth").add( |
| 25 | 30 | HttpApiEndpoint.get("me", "/auth/me", { success: SourceResponse }), |
| 26 | 31 | ); |
| 27 | −const AuthApi = HttpApi.make("authApi").add(AuthGroup); | |
| 28 | −const AuthHandlers = HttpApiBuilder.group(AuthApi, "auth", (handlers) => | |
| 29 | − handlers.handle("me", () => Effect.succeed({ source: "auth" })), | |
| 30 | −); | |
| 32 | +const AuthTestApi = HttpApi.make("authApi").add(AuthGroup); | |
| 31 | 33 | |
| 32 | 34 | const ProtectedGroup = HttpApiGroup.make("protected") |
| 33 | 35 | .add(HttpApiEndpoint.get("scope", "/scope", { success: SourceResponse })) |
| @@ -43,93 +45,104 @@ const ProtectedGroup = HttpApiGroup.make("protected") · 9 unmodified lines | ||
| 43 | 45 | success: SourceResponse, |
| 44 | 46 | }), |
| 45 | 47 | ); |
| 46 | −const ProtectedApi = HttpApi.make("protectedApi").add(ProtectedGroup); | |
| 47 | −const ProtectedHandlers = HttpApiBuilder.group(ProtectedApi, "protected", (handlers) => | |
| 48 | − handlers | |
| 49 | − .handle("scope", () => Effect.succeed({ source: "protected" })) | |
| 50 | − .handle("sources", ({ params }) => Effect.succeed({ source: params.scopeId })) | |
| 51 | − .handle("resume", () => Effect.succeed({ source: "protected" })), | |
| 52 | −); | |
| 48 | +const ProtectedTestApi = HttpApi.make("protectedApi").add(ProtectedGroup); | |
| 53 | 49 | |
| 54 | −const toHttpApp = ( | |
| 55 | − apiLayer: Parameters<typeof HttpRouter.toWebHandler>[0], | |
| 56 | −) => | |
| 57 | − Effect.gen(function* () { | |
| 58 | − const request = yield* HttpServerRequest.HttpServerRequest; | |
| 59 | − const webRequest = yield* HttpServerRequest.toWeb(request); | |
| 60 | − const web = HttpRouter.toWebHandler(apiLayer, { disableLogger: true }); | |
| 61 | − const response = yield* Effect.promise(() => web.handler(webRequest, undefined)); | |
| 62 | − return HttpServerResponse.raw(response, { status: response.status, headers: response.headers }); | |
| 63 | − }); | |
| 64 | − | |
| 65 | −const OrgTestApp = toHttpApp( | |
| 66 | − HttpApiBuilder.layer(OrgTestApi).pipe( | |
| 67 | − Layer.provide(OrgTestHandlers), | |
| 68 | − Layer.provideMerge(HttpServer.layerServices), | |
| 69 | − Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), | |
| 70 | − ) as Parameters<typeof HttpRouter.toWebHandler>[0], | |
| 71 | −); | |
| 50 | +// --------------------------------------------------------------------------- | |
| 51 | +// Stub handlers | |
| 52 | +// --------------------------------------------------------------------------- | |
| 72 | 53 | |
| 73 | −const AuthTestApp = toHttpApp( | |
| 74 | − HttpApiBuilder.layer(AuthApi).pipe( | |
| 75 | − Layer.provide(AuthHandlers), | |
| 76 | − Layer.provideMerge(HttpServer.layerServices), | |
| 77 | − Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), | |
| 78 | − ) as Parameters<typeof HttpRouter.toWebHandler>[0], | |
| 54 | +const OrgTestHandlers = HttpApiBuilder.group(OrgTestApi, "org", (handlers) => | |
| 55 | + handlers.handle("ping", () => Effect.succeed({ source: "org" })), | |
| 79 | 56 | ); |
| 80 | 57 | |
| 81 | −const ProtectedBaseTestApp = toHttpApp( | |
| 82 | − HttpApiBuilder.layer(ProtectedApi).pipe( | |
| 83 | − Layer.provide(ProtectedHandlers), | |
| 84 | − Layer.provideMerge(HttpServer.layerServices), | |
| 85 | − Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), | |
| 86 | − ) as Parameters<typeof HttpRouter.toWebHandler>[0], | |
| 58 | +const AuthHandlers = HttpApiBuilder.group(AuthTestApi, "auth", (handlers) => | |
| 59 | + handlers.handle("me", () => Effect.succeed({ source: "auth" })), | |
| 87 | 60 | ); |
| 88 | 61 | |
| 89 | −type ProtectedMode = "ok" | "none" | "error" | "bad-status"; | |
| 62 | +const ProtectedHandlers = HttpApiBuilder.group(ProtectedTestApi, "protected", (handlers) => | |
| 63 | + handlers | |
| 64 | + .handle("scope", () => Effect.succeed({ source: "protected" })) | |
| 65 | + .handle("sources", ({ params }) => Effect.succeed({ source: params.scopeId })) | |
| 66 | + .handle("resume", () => Effect.succeed({ source: "protected" })), | |
| 67 | +); | |
| 90 | 68 | |
| 91 | −const testState: { | |
| 92 | − mode: ProtectedMode; | |
| 93 | −} = { | |
| 94 | − mode: "ok", | |
| 95 | −}; | |
| 69 | +// --------------------------------------------------------------------------- | |
| 70 | +// Per-test mode switch — controls a router-level gate that mirrors the prod | |
| 71 | +// `ExecutionStackMiddleware`'s short-circuit branches without standing up | |
| 72 | +// the full WorkOS / executor stack. | |
| 73 | +// --------------------------------------------------------------------------- | |
| 96 | 74 | |
| 75 | +type ProtectedMode = "ok" | "none" | "error" | "bad-status"; | |
| 76 | +const testState: { mode: ProtectedMode } = { mode: "ok" }; | |
| 97 | 77 | const resetState = () => { |
| 98 | 78 | testState.mode = "ok"; |
| 99 | 79 | }; |
| 100 | 80 | |
| 101 | −const ProtectedTestApp = Effect.gen(function* () { | |
| 102 | − if (testState.mode === "none") { | |
| 103 | − return HttpServerResponse.jsonUnsafe( | |
| 104 | − { error: "No organization in session", code: "no_organization" }, | |
| 105 | − { status: 403 }, | |
| 106 | − ); | |
| 107 | − } | |
| 108 | − if (testState.mode === "error") { | |
| 109 | − return HttpServerResponse.jsonUnsafe({ error: "boom" }, { status: 500 }); | |
| 110 | − } | |
| 111 | − if (testState.mode === "bad-status") { | |
| 112 | − return HttpServerResponse.jsonUnsafe({ source: "protected" }, { status: 400 }); | |
| 113 | − } | |
| 114 | − return yield* ProtectedBaseTestApp; | |
| 115 | −}); | |
| 116 | − | |
| 117 | −const TestRequestHandlersLive = Layer.mergeAll( | |
| 118 | − Layer.succeed(OrgRequestHandlerService)({ app: OrgTestApp }), | |
| 119 | − Layer.succeed(NonProtectedRequestHandlerService)({ app: AuthTestApp }), | |
| 120 | − Layer.succeed(AutumnRequestHandlerService)({ | |
| 121 | − app: Effect.succeed(HttpServerResponse.jsonUnsafe({ source: "autumn" })), | |
| 81 | +// `Effect.suspend` so `testState.mode` is read per request — the | |
| 82 | +// middleware function itself runs once at addAll time, wrapping each | |
| 83 | +// route's handler. Without `suspend`, only the build-time mode ("ok") | |
| 84 | +// would be observed. | |
| 85 | +const TestProtectedGate = HttpRouter.middleware()((httpEffect) => | |
| 86 | + Effect.suspend(() => { | |
| 87 | + if (testState.mode === "none") { | |
| 88 | + return Effect.succeed( | |
| 89 | + HttpServerResponse.jsonUnsafe( | |
| 90 | + { error: "No organization in session", code: "no_organization" }, | |
| 91 | + { status: 403 }, | |
| 92 | + ), | |
| 93 | + ); | |
| 94 | + } | |
| 95 | + if (testState.mode === "error") { | |
| 96 | + return Effect.succeed( | |
| 97 | + HttpServerResponse.jsonUnsafe({ error: "boom" }, { status: 500 }), | |
| 98 | + ); | |
| 99 | + } | |
| 100 | + if (testState.mode === "bad-status") { | |
| 101 | + return Effect.succeed( | |
| 102 | + HttpServerResponse.jsonUnsafe({ source: "protected" }, { status: 400 }), | |
| 103 | + ); | |
| 104 | + } | |
| 105 | + return httpEffect; | |
| 122 | 106 | }), |
| 123 | − Layer.succeed(ProtectedRequestHandlerService)({ app: ProtectedTestApp }), | |
| 107 | +).layer; | |
| 108 | + | |
| 109 | +// --------------------------------------------------------------------------- | |
| 110 | +// Wire test APIs as route layers + autumn route, mirroring prod's structure. | |
| 111 | +// --------------------------------------------------------------------------- | |
| 112 | + | |
| 113 | +const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); | |
| 114 | + | |
| 115 | +const OrgTestLive = HttpApiBuilder.layer(OrgTestApi).pipe(Layer.provide(OrgTestHandlers)); | |
| 116 | +const AuthTestLive = HttpApiBuilder.layer(AuthTestApi).pipe(Layer.provide(AuthHandlers)); | |
| 117 | +const ProtectedTestLive = HttpApiBuilder.layer(ProtectedTestApi).pipe( | |
| 118 | + Layer.provide(ProtectedHandlers), | |
| 119 | + Layer.provide(TestProtectedGate), | |
| 124 | 120 | ); |
| 125 | 121 | |
| 126 | −const requestHandler = Effect.runSync( | |
| 127 | − Effect.map( | |
| 128 | − Effect.provide(ApiRequestHandler, TestRequestHandlersLive), | |
| 129 | − (app) => HttpEffect.toWebHandler(app), | |
| 130 | − ), | |
| 122 | +const AutumnTestRoutesLive = HttpRouter.add( | |
| 123 | + "*", | |
| 124 | + "/autumn/*", | |
| 125 | + Effect.succeed(HttpServerResponse.jsonUnsafe({ source: "autumn" })), | |
| 131 | 126 | ); |
| 132 | 127 | |
| 128 | +const TestApiLive = Layer.mergeAll( | |
| 129 | + OrgTestLive, | |
| 130 | + AuthTestLive, | |
| 131 | + ProtectedTestLive, | |
| 132 | + AutumnTestRoutesLive, | |
| 133 | +).pipe( | |
| 134 | + Layer.provideMerge(RouterConfig), | |
| 135 | + Layer.provideMerge(HttpServer.layerServices), | |
| 136 | +); | |
| 137 | + | |
| 138 | +const requestHandler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler; | |
| 139 | + | |
| 140 | +// --------------------------------------------------------------------------- | |
| 141 | +// Client setup — route HttpClient calls through the web handler in-process | |
| 142 | +// so the suite runs in any runtime (workerd's `NodeHttpServer.layerTest` | |
| 143 | +// crashes the isolate). | |
| 144 | +// --------------------------------------------------------------------------- | |
| 145 | + | |
| 133 | 146 | const TestApi = HttpApi.make("testApi") |
| 134 | 147 | .add(OrgGroup) |
| 135 | 148 | .add(AuthGroup) |
| @@ -140,9 +153,6 @@ const TestApi = HttpApi.make("testApi") · 4 unmodified lines | ||
| 140 | 153 | ) |
| 141 | 154 | .add(ProtectedGroup); |
| 142 | 155 | |
| 143 | −// Route HttpClient calls directly through the web handler — no real HTTP | |
| 144 | −// server, so the suite runs in any runtime (including workerd, where | |
| 145 | −// NodeHttpServer.layerTest crashes the isolate). | |
| 146 | 156 | const TEST_BASE_URL = "http://test.local"; |
| 147 | 157 | const fetchViaHandler: typeof globalThis.fetch = (input, init) => |
| 148 | 158 | requestHandler(input instanceof Request ? input : new Request(input, init)); |
| @@ -190,7 +200,7 @@ layer(TestClientLayer)("handleApiRequest", (it) => { · 41 unmodified lines | ||
| 190 | 200 | }), |
| 191 | 201 | ); |
| 192 | 202 | |
| 193 | − it.effect("returns 403 when protected handler returns no organization", () => | |
| 203 | + it.effect("returns 403 when protected gate short-circuits with no organization", () => | |
| 194 | 204 | Effect.gen(function* () { |
| 195 | 205 | resetState(); |
| 196 | 206 | testState.mode = "none"; |
| @@ -214,7 +224,7 @@ layer(TestClientLayer)("handleApiRequest", (it) => { · 17 unmodified lines | ||
| 214 | 224 | }), |
| 215 | 225 | ); |
| 216 | 226 | |
| 217 | − it.effect("preserves protected path params through the outer router", () => | |
| 227 | + it.effect("preserves protected path params", () => | |
| 218 | 228 | Effect.gen(function* () { |
| 219 | 229 | resetState(); |
| 220 | 230 | const client = yield* getClient(); |
| @@ -233,7 +243,7 @@ layer(TestClientLayer)("handleApiRequest", (it) => { · 12 unmodified lines | ||
| 233 | 243 | }), |
| 234 | 244 | ); |
| 235 | 245 | |
| 236 | − it.effect("returns 500 JSON when protected request handling throws", () => | |
| 246 | + it.effect("returns 500 JSON when protected gate returns 500", () => | |
| 237 | 247 | Effect.gen(function* () { |
| 238 | 248 | resetState(); |
| 239 | 249 | testState.mode = "error"; |
| @@ -1,26 +1,5 @@ | ||
| 1 | −import { Effect, Layer } from "effect"; | |
| 2 | −import { HttpEffect } from "effect/unstable/http"; | |
| 3 | −import { AutumnApiApp } from "./api/autumn"; | |
| 4 | −import { NonProtectedApiApp, OrgApiApp } from "./api/layers"; | |
| 5 | −import { ProtectedApiApp } from "./api/protected"; | |
| 6 | −import { | |
| 7 | − ApiRequestHandler, | |
| 8 | − AutumnRequestHandlerService, | |
| 9 | − NonProtectedRequestHandlerService, | |
| 10 | − ProtectedRequestHandlerService, | |
| 11 | − OrgRequestHandlerService, | |
| 12 | −} from "./api/router"; | |
| 1 | +import { HttpRouter } from "effect/unstable/http"; | |
| 13 | 2 | |
| 14 | −const ApiRequestHandlersLive = Layer.mergeAll( | |
| 15 | − Layer.succeed(OrgRequestHandlerService)({ app: OrgApiApp }), | |
| 16 | − Layer.succeed(NonProtectedRequestHandlerService)({ app: NonProtectedApiApp }), | |
| 17 | − Layer.succeed(AutumnRequestHandlerService)({ app: AutumnApiApp }), | |
| 18 | − Layer.succeed(ProtectedRequestHandlerService)({ app: ProtectedApiApp }), | |
| 19 | −); | |
| 3 | +import { ApiLive } from "./api/router"; | |
| 20 | 4 | |
| 21 | −export const handleApiRequest = Effect.runSync( | |
| 22 | − Effect.map( | |
| 23 | − Effect.provide(ApiRequestHandler, ApiRequestHandlersLive), | |
| 24 | − (app) => HttpEffect.toWebHandler(app), | |
| 25 | − ), | |
| 26 | −); | |
| 5 | +export const handleApiRequest = HttpRouter.toWebHandler(ApiLive).handler; | |
| @@ -1,13 +1,20 @@ | ||
| 1 | 1 | import { env } from "cloudflare:workers"; |
| 2 | 2 | import { Effect } from "effect"; |
| 3 | −import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 3 | +import { | |
| 4 | + HttpRouter, | |
| 5 | + HttpServerRequest, | |
| 6 | + HttpServerResponse, | |
| 7 | +} from "effect/unstable/http"; | |
| 4 | 8 | import { autumnHandler } from "autumn-js/backend"; |
| 5 | 9 | |
| 6 | 10 | import { WorkOSAuth } from "../auth/workos"; |
| 7 | −import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; | |
| 8 | −import { SharedServices } from "./layers"; | |
| 11 | +import { | |
| 12 | + HttpResponseError, | |
| 13 | + isServerError, | |
| 14 | + toErrorServerResponse, | |
| 15 | +} from "./error-response"; | |
| 9 | 16 | |
| 10 | −export const AutumnApiApp = Effect.gen(function* () { | |
| 17 | +const handler = Effect.gen(function* () { | |
| 11 | 18 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 12 | 19 | const webRequest = yield* Effect.mapError( |
| 13 | 20 | HttpServerRequest.toWeb(request), |
| @@ -78,7 +85,6 @@ export const AutumnApiApp = Effect.gen(function* () { · 64 unmodified lines | ||
| 78 | 85 | |
| 79 | 86 | return HttpServerResponse.jsonUnsafe(response, { status: statusCode }); |
| 80 | 87 | }).pipe( |
| 81 | − Effect.provide(SharedServices), | |
| 82 | 88 | Effect.catchCause((err) => { |
| 83 | 89 | if (isServerError(err)) { |
| 84 | 90 | console.error("[autumn] request failed:", err instanceof Error ? err.stack : err); |
| @@ -86,3 +92,5 @@ export const AutumnApiApp = Effect.gen(function* () { · 1 unmodified line | ||
| 86 | 92 | return Effect.succeed(toErrorServerResponse(err)); |
| 87 | 93 | }), |
| 88 | 94 | ); |
| 95 | + | |
| 96 | +export const AutumnRoutesLive = HttpRouter.add("*", "/autumn/*", handler); | |
| @@ -1,12 +1,29 @@ | ||
| 1 | 1 | import * as Sentry from "@sentry/cloudflare"; |
| 2 | −import { Data } from "effect"; | |
| 3 | −import { HttpServerResponse } from "effect/unstable/http"; | |
| 2 | +import { Data, Effect } from "effect"; | |
| 3 | +import { | |
| 4 | + HttpServerRespondable, | |
| 5 | + HttpServerResponse, | |
| 6 | +} from "effect/unstable/http"; | |
| 4 | 7 | |
| 8 | +// Implements `Respondable` so the framework's default cause→response | |
| 9 | +// pipeline (`HttpServerRespondable.toResponseOrElse`) renders this as the | |
| 10 | +// declared JSON body + status code without an explicit `catchCause` at | |
| 11 | +// every error boundary. | |
| 5 | 12 | export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{ |
| 6 | 13 | readonly status: number; |
| 7 | 14 | readonly code: string; |
| 8 | 15 | readonly message: string; |
| 9 | −}> {} | |
| 16 | +}> { | |
| 17 | + [HttpServerRespondable.symbol](): Effect.Effect<HttpServerResponse.HttpServerResponse> { | |
| 18 | + if (this.status >= 500) Sentry.captureException(this); | |
| 19 | + return Effect.succeed( | |
| 20 | + HttpServerResponse.jsonUnsafe( | |
| 21 | + { error: this.message, code: this.code }, | |
| 22 | + { status: this.status }, | |
| 23 | + ), | |
| 24 | + ); | |
| 25 | + } | |
| 26 | +} | |
| 10 | 27 | |
| 11 | 28 | const toHttpResponseError = (error: unknown): HttpResponseError => |
| 12 | 29 | error instanceof HttpResponseError |
| @@ -1,14 +1,7 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "effect/unstable/httpapi"; |
| 2 | −import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"; | |
| 3 | −import { Effect, Layer } from "effect"; | |
| 2 | +import { HttpServer } from "effect/unstable/http"; | |
| 3 | +import { Layer } from "effect"; | |
| 4 | 4 | |
| 5 | −import { CoreExecutorApi, observabilityMiddleware } from "@executor-js/api"; | |
| 6 | −import { CoreHandlers } from "@executor-js/api/server"; | |
| 7 | −import { OpenApiGroup, OpenApiHandlers } from "@executor-js/plugin-openapi/api"; | |
| 8 | −import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; | |
| 9 | −import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api"; | |
| 10 | − | |
| 11 | −import { OrgAuth } from "../auth/middleware"; | |
| 12 | 5 | import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; |
| 13 | 6 | import { UserStoreService } from "../auth/context"; |
| 14 | 7 | import { |
| @@ -20,18 +13,15 @@ import { DbService } from "../services/db"; · 5 unmodified lines | ||
| 20 | 13 | import { TelemetryLive } from "../services/telemetry"; |
| 21 | 14 | import { OrgHttpApi } from "../org/compose"; |
| 22 | 15 | import { OrgHandlers } from "../org/handlers"; |
| 23 | −import { ErrorCaptureLive } from "../observability"; | |
| 24 | 16 | |
| 25 | 17 | import { CoreSharedServices } from "./core-shared-services"; |
| 18 | +import { ProtectedCloudApi, RouterConfig } from "./protected-layers"; | |
| 26 | 19 | |
| 27 | −export { CoreSharedServices }; | |
| 28 | − | |
| 29 | −export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) | |
| 30 | − .add(McpGroup) | |
| 31 | − .add(GraphqlGroup) | |
| 32 | − .middleware(OrgAuth); | |
| 33 | − | |
| 34 | −const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); | |
| 20 | +export { | |
| 21 | + CoreSharedServices, | |
| 22 | + ProtectedCloudApi, | |
| 23 | + RouterConfig, | |
| 24 | +}; | |
| 35 | 25 | |
| 36 | 26 | const DbLive = DbService.Live; |
| 37 | 27 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); |
| @@ -44,47 +34,16 @@ export const SharedServices = Layer.mergeAll( · 6 unmodified lines | ||
| 44 | 34 | TelemetryLive, |
| 45 | 35 | ); |
| 46 | 36 | |
| 47 | −export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); | |
| 48 | − | |
| 49 | −export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( | |
| 50 | − Layer.provide( | |
| 51 | − Layer.mergeAll( | |
| 52 | − CoreHandlers, | |
| 53 | − OpenApiHandlers, | |
| 54 | − McpHandlers, | |
| 55 | − GraphqlHandlers, | |
| 56 | − OrgAuthLive, | |
| 57 | − ObservabilityLive, | |
| 58 | − ), | |
| 59 | − ), | |
| 60 | − Layer.provide(ErrorCaptureLive), | |
| 61 | −); | |
| 62 | − | |
| 63 | −const NonProtectedApiLive = HttpApiBuilder.layer(NonProtectedApi).pipe( | |
| 37 | +// Routes that don't require an authenticated org session — login, | |
| 38 | +// callbacks, etc. Mounts at the paths declared inside `NonProtectedApi`. | |
| 39 | +export const NonProtectedApiLive = HttpApiBuilder.layer(NonProtectedApi).pipe( | |
| 64 | 40 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), |
| 65 | 41 | Layer.provideMerge(SessionAuthLive), |
| 66 | 42 | ); |
| 67 | 43 | |
| 68 | −const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe( | |
| 44 | +// Routes scoped to a specific org (membership management, switching, etc.). | |
| 45 | +// Auth is enforced by `OrgAuth` middleware declared on `OrgHttpApi`. | |
| 46 | +export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe( | |
| 69 | 47 | Layer.provide(OrgHandlers), |
| 70 | 48 | Layer.provideMerge(OrgAuthLive), |
| 71 | 49 | ); |
| 72 | − | |
| 73 | −const NonProtectedRequestLayer = NonProtectedApiLive.pipe( | |
| 74 | − Layer.provideMerge(RouterConfig), | |
| 75 | − Layer.provideMerge(HttpServer.layerServices), | |
| 76 | −); | |
| 77 | − | |
| 78 | −const OrgRequestLayer = OrgApiLive.pipe( | |
| 79 | − Layer.provideMerge(RouterConfig), | |
| 80 | −); | |
| 81 | − | |
| 82 | −export const NonProtectedApiApp = Effect.flatMap( | |
| 83 | − HttpRouter.toHttpEffect(NonProtectedRequestLayer), | |
| 84 | − HttpMiddleware.logger, | |
| 85 | −).pipe(Effect.provide(SharedServices)); | |
| 86 | − | |
| 87 | −export const OrgApiApp = Effect.flatMap( | |
| 88 | − HttpRouter.toHttpEffect(OrgRequestLayer), | |
| 89 | − HttpMiddleware.logger, | |
| 90 | −).pipe(Effect.provide(SharedServices)); | |
| @@ -16,18 +16,23 @@ import { OpenApiGroup, OpenApiHandlers } from "@executor-js/plugin-openapi/api"; · 15 unmodified lines | ||
| 16 | 16 | import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; |
| 17 | 17 | import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api"; |
| 18 | 18 | |
| 19 | −import { OrgAuth } from "../auth/middleware"; | |
| 20 | −import { OrgAuthLive } from "../auth/middleware-live"; | |
| 21 | 19 | import { UserStoreService } from "../auth/context"; |
| 22 | 20 | import { WorkOSAuth } from "../auth/workos"; |
| 23 | 21 | import { AutumnService } from "../services/autumn"; |
| 24 | 22 | import { DbService } from "../services/db"; |
| 25 | 23 | import { ErrorCaptureLive } from "../observability"; |
| 26 | 24 | |
| 25 | +// `ProtectedCloudApi` deliberately does NOT declare `.middleware(OrgAuth)` | |
| 26 | +// — auth + per-request execution stack construction live in a single | |
| 27 | +// `HttpRouter` middleware (`ExecutionStackMiddleware` in `./protected.ts`) | |
| 28 | +// which has the right ordering to provide `AuthContext` AND the executor | |
| 29 | +// services to handlers. Putting auth on the API as `HttpApiMiddleware` ran | |
| 30 | +// it INSIDE the router middleware (wrong order), and added a second auth | |
| 31 | +// pass on top of the existing one in `protected.ts`'s outer effect. The | |
| 32 | +// router-middleware approach folds both into one place. | |
| 27 | 33 | export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) |
| 28 | 34 | .add(McpGroup) |
| 29 | − .add(GraphqlGroup) | |
| 30 | − .middleware(OrgAuth); | |
| 35 | + .add(GraphqlGroup); | |
| 31 | 36 | |
| 32 | 37 | const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); |
| 33 | 38 | |
| @@ -44,9 +49,9 @@ export const SharedServices = Layer.mergeAll( · 10 unmodified lines | ||
| 44 | 49 | |
| 45 | 50 | export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); |
| 46 | 51 | |
| 47 | −// Every handler the ProtectedCloudApi routes to, minus auth. The test | |
| 48 | −// harness builds its own api-live by merging this with a fake OrgAuth | |
| 49 | −// layer; prod merges it with OrgAuthLive below. | |
| 52 | +// Every handler the ProtectedCloudApi routes to. The test harness builds | |
| 53 | +// its own api-live by merging this with its own per-request middleware | |
| 54 | +// fakes; prod uses `ProtectedCloudApiLive` below. | |
| 50 | 55 | export const ProtectedCloudApiHandlers = Layer.mergeAll( |
| 51 | 56 | CoreHandlers, |
| 52 | 57 | OpenApiHandlers, |
| @@ -59,8 +64,6 @@ export const ProtectedCloudApiHandlers = Layer.mergeAll( · 6 unmodified lines | ||
| 59 | 64 | // InternalError(traceId)`) AND the observability middleware's defect |
| 60 | 65 | // catchall both see the same Sentry-backed implementation. |
| 61 | 66 | export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( |
| 62 | − Layer.provide( | |
| 63 | − Layer.mergeAll(ProtectedCloudApiHandlers, OrgAuthLive, ObservabilityLive), | |
| 64 | − ), | |
| 67 | + Layer.provide(Layer.mergeAll(ProtectedCloudApiHandlers, ObservabilityLive)), | |
| 65 | 68 | Layer.provide(ErrorCaptureLive), |
| 66 | 69 | ); |
| @@ -1,93 +1,114 @@ | ||
| 1 | +// Production wiring for the protected API. Lives outside `protected-layers.ts` | |
| 2 | +// because `makeExecutionStack` imports `cloudflare:workers`, which the test | |
| 3 | +// harness can't load in the workerd test runtime. | |
| 4 | + | |
| 1 | 5 | import { HttpApiSwagger } from "effect/unstable/httpapi"; |
| 2 | 6 | import { |
| 3 | − HttpMiddleware, | |
| 4 | 7 | HttpRouter, |
| 5 | 8 | HttpServerRequest, |
| 6 | 9 | } from "effect/unstable/http"; |
| 7 | 10 | import { Effect, Layer } from "effect"; |
| 8 | 11 | |
| 9 | −import { ExecutorService, ExecutionEngineService } from "@executor-js/api/server"; | |
| 12 | +import { | |
| 13 | + ExecutionEngineService, | |
| 14 | + ExecutorService, | |
| 15 | +} from "@executor-js/api/server"; | |
| 10 | 16 | import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; |
| 11 | 17 | import { McpExtensionService } from "@executor-js/plugin-mcp/api"; |
| 12 | 18 | import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; |
| 13 | 19 | |
| 20 | +import { AuthContext } from "../auth/middleware"; | |
| 14 | 21 | import { authorizeOrganization } from "../auth/authorize-organization"; |
| 22 | +import { UserStoreService } from "../auth/context"; | |
| 15 | 23 | import { WorkOSAuth } from "../auth/workos"; |
| 24 | +import { AutumnService } from "../services/autumn"; | |
| 25 | +import { DbService } from "../services/db"; | |
| 16 | 26 | import { makeExecutionStack } from "../services/execution-stack"; |
| 17 | −import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; | |
| 18 | −import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers"; | |
| 19 | − | |
| 20 | −const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => | |
| 21 | − Effect.gen(function* () { | |
| 22 | − const webRequest = yield* Effect.mapError( | |
| 23 | − HttpServerRequest.toWeb(request), | |
| 24 | − () => | |
| 25 | − new HttpResponseError({ | |
| 26 | − status: 500, | |
| 27 | − code: "invalid_request", | |
| 28 | − message: "Invalid request", | |
| 29 | − }), | |
| 30 | − ); | |
| 31 | − const workos = yield* WorkOSAuth; | |
| 32 | − const session = yield* workos.authenticateRequest(webRequest); | |
| 33 | − if (!session || !session.organizationId) return null; | |
| 34 | − | |
| 35 | − const org = yield* authorizeOrganization(session.userId, session.organizationId); | |
| 36 | − if (!org) return null; | |
| 37 | − return { org, userId: session.userId }; | |
| 38 | − }); | |
| 27 | +import { HttpResponseError } from "./error-response"; | |
| 28 | +import { | |
| 29 | + ProtectedCloudApi, | |
| 30 | + ProtectedCloudApiLive, | |
| 31 | + RouterConfig, | |
| 32 | +} from "./protected-layers"; | |
| 39 | 33 | |
| 40 | −const createProtectedApp = ( | |
| 41 | − userId: string, | |
| 42 | − organizationId: string, | |
| 43 | − organizationName: string, | |
| 44 | −) => | |
| 34 | +// One `HttpRouter` middleware that: | |
| 35 | +// 1. authenticates the WorkOS sealed session, | |
| 36 | +// 2. verifies live org membership (closes the JWT-cache gap — see | |
| 37 | +// `auth/authorize-organization.ts`), | |
| 38 | +// 3. resolves the org name, | |
| 39 | +// 4. builds the per-request executor + engine, | |
| 40 | +// 5. provides `AuthContext` + the execution-stack services to the handler. | |
| 41 | +// | |
| 42 | +// Replaces both the old outer `Effect.gen` in this file (which did its own | |
| 43 | +// WorkOS lookup) and the per-route `OrgAuth` HttpApiMiddleware (which did | |
| 44 | +// a second one). | |
| 45 | +// | |
| 46 | +// Errors are NOT caught here: failures propagate as typed errors and are | |
| 47 | +// rendered to a JSON response by the framework's `Respondable` pipeline | |
| 48 | +// (see `HttpResponseError` in `./error-response.ts`). Letting `unhandled` | |
| 49 | +// pass through is what satisfies `HttpRouter.middleware`'s brand check | |
| 50 | +// without any type casts. | |
| 51 | +const ExecutionStackMiddleware = HttpRouter.middleware<{ | |
| 52 | + provides: | |
| 53 | + | AuthContext | |
| 54 | + | ExecutorService | |
| 55 | + | ExecutionEngineService | |
| 56 | + | OpenApiExtensionService | |
| 57 | + | McpExtensionService | |
| 58 | + | GraphqlExtensionService; | |
| 59 | +}>()( | |
| 60 | + // Layer-time setup — capture the long-lived services in a closure so | |
| 61 | + // the per-request function only needs `HttpRouter`-Provided context. | |
| 62 | + // That collapses the middleware's `requires` to `never`, giving us a | |
| 63 | + // real `.layer` (instead of the "Need to .combine(...)" type-error | |
| 64 | + // sentinel that fires when `requires` leaks to non-never). | |
| 45 | 65 | Effect.gen(function* () { |
| 46 | − const { executor, engine } = yield* makeExecutionStack( | |
| 47 | − userId, | |
| 48 | − organizationId, | |
| 49 | − organizationName, | |
| 50 | − ); | |
| 51 | − | |
| 52 | − const requestServices = Layer.mergeAll( | |
| 53 | − Layer.succeed(ExecutorService)(executor), | |
| 54 | − Layer.succeed(ExecutionEngineService)(engine), | |
| 55 | − Layer.succeed(OpenApiExtensionService)(executor.openapi), | |
| 56 | − Layer.succeed(McpExtensionService)(executor.mcp), | |
| 57 | − Layer.succeed(GraphqlExtensionService)(executor.graphql), | |
| 58 | − ); | |
| 59 | − | |
| 60 | − const protectedApiLayer = ProtectedCloudApiLive.pipe( | |
| 61 | − Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), | |
| 62 | − Layer.provideMerge(RouterConfig), | |
| 63 | − ); | |
| 64 | − | |
| 65 | − return yield* HttpRouter.toHttpEffect(protectedApiLayer).pipe( | |
| 66 | − Effect.flatMap(HttpMiddleware.logger), | |
| 67 | − Effect.provide(requestServices), | |
| 68 | − ); | |
| 69 | − }); | |
| 70 | − | |
| 71 | −export const ProtectedApiApp = Effect.gen(function* () { | |
| 72 | − const request = yield* HttpServerRequest.HttpServerRequest; | |
| 73 | − const session = yield* lookupOrgForRequest(request); | |
| 74 | − if (!session) { | |
| 75 | − return yield* Effect.fail( | |
| 76 | − new HttpResponseError({ | |
| 77 | − status: 403, | |
| 78 | − code: "no_organization", | |
| 79 | − message: "No organization in session", | |
| 80 | − }), | |
| 81 | − ); | |
| 82 | − } | |
| 83 | − | |
| 84 | − return yield* createProtectedApp(session.userId, session.org.id, session.org.name); | |
| 85 | −}).pipe( | |
| 86 | − Effect.provide(SharedServices), | |
| 87 | − Effect.catchCause((err) => { | |
| 88 | − if (isServerError(err)) { | |
| 89 | − console.error("[api] request failed:", err instanceof Error ? err.stack : err); | |
| 90 | − } | |
| 91 | − return Effect.succeed(toErrorServerResponse(err)); | |
| 66 | + const context = yield* Effect.context< | |
| 67 | + WorkOSAuth | UserStoreService | AutumnService | DbService | |
| 68 | + >(); | |
| 69 | + return (httpEffect) => | |
| 70 | + Effect.gen(function* () { | |
| 71 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 72 | + const webRequest = yield* HttpServerRequest.toWeb(request); | |
| 73 | + const workos = yield* WorkOSAuth; | |
| 74 | + const session = yield* workos.authenticateRequest(webRequest); | |
| 75 | + if (!session || !session.organizationId) { | |
| 76 | + return yield* new HttpResponseError({ | |
| 77 | + status: 403, | |
| 78 | + code: "no_organization", | |
| 79 | + message: "No organization in session", | |
| 80 | + }); | |
| 81 | + } | |
| 82 | + const org = yield* authorizeOrganization(session.userId, session.organizationId); | |
| 83 | + if (!org) { | |
| 84 | + return yield* new HttpResponseError({ | |
| 85 | + status: 403, | |
| 86 | + code: "no_organization", | |
| 87 | + message: "No organization in session", | |
| 88 | + }); | |
| 89 | + } | |
| 90 | + const auth = AuthContext.of({ | |
| 91 | + accountId: session.userId, | |
| 92 | + organizationId: org.id, | |
| 93 | + email: session.email, | |
| 94 | + name: `${session.firstName ?? ""} ${session.lastName ?? ""}`.trim() || null, | |
| 95 | + avatarUrl: session.avatarUrl ?? null, | |
| 96 | + }); | |
| 97 | + const { executor, engine } = yield* makeExecutionStack(auth.accountId, org.id, org.name); | |
| 98 | + return yield* httpEffect.pipe( | |
| 99 | + Effect.provideService(AuthContext, auth), | |
| 100 | + Effect.provideService(ExecutorService, executor), | |
| 101 | + Effect.provideService(ExecutionEngineService, engine), | |
| 102 | + Effect.provideService(OpenApiExtensionService, executor.openapi), | |
| 103 | + Effect.provideService(McpExtensionService, executor.mcp), | |
| 104 | + Effect.provideService(GraphqlExtensionService, executor.graphql), | |
| 105 | + ); | |
| 106 | + }).pipe(Effect.provideContext(context)); | |
| 92 | 107 | }), |
| 108 | +).layer; | |
| 109 | + | |
| 110 | +export const ProtectedApiLive = ProtectedCloudApiLive.pipe( | |
| 111 | + Layer.provide(ExecutionStackMiddleware), | |
| 112 | + Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), | |
| 113 | + Layer.provideMerge(RouterConfig), | |
| 93 | 114 | ); |
| @@ -1,56 +1,24 @@ | ||
| 1 | −import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 2 | −import { Context, Effect, Scope } from "effect"; | |
| 3 | − | |
| 4 | −export type ApiRouteApp = Effect.Effect< | |
| 5 | − HttpServerResponse.HttpServerResponse, | |
| 6 | − unknown, | |
| 7 | − HttpServerRequest.HttpServerRequest | Scope.Scope | |
| 8 | −>; | |
| 9 | − | |
| 10 | −type RequestAppService = { | |
| 11 | − readonly app: ApiRouteApp; | |
| 12 | −}; | |
| 13 | − | |
| 14 | −export class OrgRequestHandlerService extends Context.Service<OrgRequestHandlerService, RequestAppService>()( | |
| 15 | − "@executor-js/cloud/OrgRequestHandlerService", | |
| 16 | −) {} | |
| 17 | − | |
| 18 | −export class NonProtectedRequestHandlerService extends Context.Service<NonProtectedRequestHandlerService, RequestAppService>()( | |
| 19 | − "@executor-js/cloud/NonProtectedRequestHandlerService", | |
| 20 | −) {} | |
| 21 | − | |
| 22 | −export class AutumnRequestHandlerService extends Context.Service<AutumnRequestHandlerService, RequestAppService>()( | |
| 23 | − "@executor-js/cloud/AutumnRequestHandlerService", | |
| 24 | −) {} | |
| 25 | − | |
| 26 | −export class ProtectedRequestHandlerService extends Context.Service<ProtectedRequestHandlerService, RequestAppService>()( | |
| 27 | − "@executor-js/cloud/ProtectedRequestHandlerService", | |
| 28 | −) {} | |
| 29 | − | |
| 30 | −export const ApiRouterApp = Effect.gen(function* () { | |
| 31 | − const org = yield* OrgRequestHandlerService; | |
| 32 | − const nonProtected = yield* NonProtectedRequestHandlerService; | |
| 33 | − const autumn = yield* AutumnRequestHandlerService; | |
| 34 | − const protectedHandler = yield* ProtectedRequestHandlerService; | |
| 35 | − const asRouteApp = (app: ApiRouteApp) => { | |
| 36 | − const webHandler = HttpEffect.toWebHandler(app); | |
| 37 | − return HttpEffect.fromWebHandler((request) => webHandler(request)).pipe( | |
| 38 | − Effect.catchCause(() => | |
| 39 | − Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), | |
| 40 | − ), | |
| 41 | − ); | |
| 42 | − }; | |
| 43 | − | |
| 44 | − const router = yield* HttpRouter.make; | |
| 45 | − yield* router.add("*", "/org/*", asRouteApp(org.app)); | |
| 46 | − yield* router.add("*", "/auth/*", asRouteApp(nonProtected.app)); | |
| 47 | − yield* router.add("*", "/autumn/*", asRouteApp(autumn.app)); | |
| 48 | − yield* router.add("*", "/scope", asRouteApp(protectedHandler.app)); | |
| 49 | − yield* router.add("*", "/scopes/*", asRouteApp(protectedHandler.app)); | |
| 50 | − yield* router.add("*", "/executions/*", asRouteApp(protectedHandler.app)); | |
| 51 | − yield* router.add("*", "/oauth/*", asRouteApp(protectedHandler.app)); | |
| 52 | − | |
| 53 | − return router.asHttpEffect(); | |
| 54 | −}); | |
| 55 | − | |
| 56 | −export const ApiRequestHandler = ApiRouterApp; | |
| 1 | +import { Layer } from "effect"; | |
| 2 | + | |
| 3 | +import { AutumnRoutesLive } from "./autumn"; | |
| 4 | +import { | |
| 5 | + NonProtectedApiLive, | |
| 6 | + OrgApiLive, | |
| 7 | + RouterConfig, | |
| 8 | + SharedServices, | |
| 9 | +} from "./layers"; | |
| 10 | +import { ProtectedApiLive } from "./protected"; | |
| 11 | + | |
| 12 | +// One router. Each sub-API contributes its routes via `HttpApiBuilder.layer`, | |
| 13 | +// which calls `HttpRouter.use(...)` under the hood. Autumn's catch-all proxy | |
| 14 | +// is added as a plain `HttpRouter.add` route. They all merge into the same | |
| 15 | +// routing table; there is no outer-then-inner router stacking. | |
| 16 | +export const ApiLive = Layer.mergeAll( | |
| 17 | + NonProtectedApiLive, | |
| 18 | + OrgApiLive, | |
| 19 | + ProtectedApiLive, | |
| 20 | + AutumnRoutesLive, | |
| 21 | +).pipe( | |
| 22 | + Layer.provideMerge(RouterConfig), | |
| 23 | + Layer.provideMerge(SharedServices), | |
| 24 | +); | |
| @@ -16,7 +16,12 @@ · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | import { Effect, Layer } from "effect"; |
| 18 | 18 | import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi"; |
| 19 | −import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 19 | +import { | |
| 20 | + FetchHttpClient, | |
| 21 | + HttpRouter, | |
| 22 | + HttpServer, | |
| 23 | + HttpServerRequest, | |
| 24 | +} from "effect/unstable/http"; | |
| 20 | 25 | |
| 21 | 26 | import { |
| 22 | 27 | ExecutionEngineService, |
| @@ -48,7 +53,7 @@ import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; · 25 unmodified lines | ||
| 48 | 53 | import { McpExtensionService } from "@executor-js/plugin-mcp/api"; |
| 49 | 54 | import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; |
| 50 | 55 | |
| 51 | −import { AuthContext, OrgAuth } from "../../auth/middleware"; | |
| 56 | +import { AuthContext } from "../../auth/middleware"; | |
| 52 | 57 | import { |
| 53 | 58 | ProtectedCloudApi, |
| 54 | 59 | ProtectedCloudApiHandlers, |
| @@ -209,10 +214,27 @@ const createTestScopedExecutor = ( · 154 unmodified lines | ||
| 209 | 214 | // HTTP plumbing |
| 210 | 215 | // --------------------------------------------------------------------------- |
| 211 | 216 | |
| 212 | −const FakeOrgAuthLive = Layer.succeed( | |
| 213 | − OrgAuth, | |
| 214 | − { | |
| 215 | − cookie: (httpApp) => | |
| 217 | +// Test version of the production `ExecutionStackMiddleware` — reads the | |
| 218 | +// `x-test-org-id` (and optional `x-test-user-id`) header, builds a | |
| 219 | +// test-scoped executor against the live postgres test db with a fake | |
| 220 | +// WorkOS vault, and provides `AuthContext` + the executor services to the | |
| 221 | +// handler. Mirrors prod's HttpRouter middleware but with test-mode | |
| 222 | +// constructors. | |
| 223 | +const TestExecutionStackMiddleware = HttpRouter.middleware<{ | |
| 224 | + provides: | |
| 225 | + | AuthContext | |
| 226 | + | ExecutorService | |
| 227 | + | ExecutionEngineService | |
| 228 | + | OpenApiExtensionService | |
| 229 | + | McpExtensionService | |
| 230 | + | GraphqlExtensionService; | |
| 231 | +}>()( | |
| 232 | + // Layer-time setup — captures `DbService` so the per-request function | |
| 233 | + // only depends on `HttpRouter`-Provided context. See `api/protected.ts` | |
| 234 | + // for the same pattern. | |
| 235 | + Effect.gen(function* () { | |
| 236 | + const context = yield* Effect.context<DbService>(); | |
| 237 | + return (httpEffect) => | |
| 216 | 238 | Effect.gen(function* () { |
| 217 | 239 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 218 | 240 | const orgId = request.headers[TEST_ORG_HEADER]; |
| @@ -224,7 +246,13 @@ const FakeOrgAuthLive = Layer.succeed( · 5 unmodified lines | ||
| 224 | 246 | typeof userHeader === "string" && userHeader.length > 0 |
| 225 | 247 | ? userHeader |
| 226 | 248 | : defaultUserFor(orgId); |
| 227 | − return yield* httpApp.pipe( | |
| 249 | + const orgName = `Org ${orgId}`; | |
| 250 | + const executor = yield* createTestScopedExecutor(userId, orgId, orgName); | |
| 251 | + const engine = createExecutionEngine({ | |
| 252 | + executor, | |
| 253 | + codeExecutor: makeQuickJsExecutor(), | |
| 254 | + }); | |
| 255 | + return yield* httpEffect.pipe( | |
| 228 | 256 | Effect.provideService( |
| 229 | 257 | AuthContext, |
| 230 | 258 | AuthContext.of({ |
| @@ -235,66 +263,26 @@ const FakeOrgAuthLive = Layer.succeed( · 4 unmodified lines | ||
| 235 | 263 | avatarUrl: null, |
| 236 | 264 | }), |
| 237 | 265 | ), |
| 266 | + Effect.provideService(ExecutorService, executor), | |
| 267 | + Effect.provideService(ExecutionEngineService, engine), | |
| 268 | + Effect.provideService(OpenApiExtensionService, executor.openapi), | |
| 269 | + Effect.provideService(McpExtensionService, executor.mcp), | |
| 270 | + Effect.provideService(GraphqlExtensionService, executor.graphql), | |
| 238 | 271 | ); |
| 239 | − }), | |
| 240 | − }, | |
| 241 | −); | |
| 272 | + }).pipe(Effect.provideContext(context)); | |
| 273 | + }), | |
| 274 | +).layer; | |
| 242 | 275 | |
| 243 | 276 | const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( |
| 244 | − Layer.provide(Layer.merge(ProtectedCloudApiHandlers, FakeOrgAuthLive)), | |
| 277 | + Layer.provide(ProtectedCloudApiHandlers), | |
| 278 | + Layer.provide(TestExecutionStackMiddleware), | |
| 279 | + Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), | |
| 280 | + Layer.provideMerge(RouterConfig), | |
| 281 | + Layer.provideMerge(DbService.Live), | |
| 282 | + Layer.provideMerge(HttpServer.layerServices), | |
| 245 | 283 | ); |
| 246 | 284 | |
| 247 | −const buildWebHandlerForScope = (userId: string, orgId: string, orgName: string) => | |
| 248 | − Effect.gen(function* () { | |
| 249 | − const executor = yield* createTestScopedExecutor(userId, orgId, orgName); | |
| 250 | − const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); | |
| 251 | − const services = Layer.mergeAll( | |
| 252 | − Layer.succeed(ExecutorService)(executor), | |
| 253 | − Layer.succeed(ExecutionEngineService)(engine), | |
| 254 | − Layer.succeed(OpenApiExtensionService)(executor.openapi), | |
| 255 | − Layer.succeed(McpExtensionService)(executor.mcp), | |
| 256 | − Layer.succeed(GraphqlExtensionService)(executor.graphql), | |
| 257 | − ); | |
| 258 | − return HttpRouter.toWebHandler( | |
| 259 | − TestApiLive.pipe( | |
| 260 | − Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), | |
| 261 | − Layer.provideMerge(services), | |
| 262 | − Layer.provideMerge(RouterConfig), | |
| 263 | − Layer.provideMerge(HttpServer.layerServices), | |
| 264 | − ), | |
| 265 | − { disableLogger: true }, | |
| 266 | − ); | |
| 267 | − }); | |
| 268 | − | |
| 269 | −const RouterApp = Effect.gen(function* () { | |
| 270 | − const request = yield* HttpServerRequest.HttpServerRequest; | |
| 271 | − const webRequest = yield* HttpServerRequest.toWeb(request); | |
| 272 | − const orgId = request.headers[TEST_ORG_HEADER]; | |
| 273 | − if (!orgId || typeof orgId !== "string") { | |
| 274 | − return yield* Effect.die(new Error("missing x-test-org-id")); | |
| 275 | − } | |
| 276 | − const userHeader = request.headers[TEST_USER_HEADER]; | |
| 277 | − const userId = | |
| 278 | − typeof userHeader === "string" && userHeader.length > 0 | |
| 279 | − ? userHeader | |
| 280 | − : defaultUserFor(orgId); | |
| 281 | − const web = yield* buildWebHandlerForScope(userId, orgId, `Org ${orgId}`); | |
| 282 | − const response = yield* Effect.promise(async () => { | |
| 283 | − try { | |
| 284 | − return await web.handler(webRequest); | |
| 285 | − } finally { | |
| 286 | − await web.dispose(); | |
| 287 | − } | |
| 288 | − }); | |
| 289 | − return HttpServerResponse.raw(response, { status: response.status, headers: response.headers }); | |
| 290 | −}); | |
| 291 | − | |
| 292 | −const handler = HttpEffect.toWebHandler( | |
| 293 | − RouterApp.pipe( | |
| 294 | − Effect.provide(DbService.Live), | |
| 295 | − Effect.provide(HttpServer.layerServices), | |
| 296 | − ), | |
| 297 | −); | |
| 285 | +const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler; | |
| 298 | 286 | |
| 299 | 287 | export const fetchForOrg = (orgId: string): typeof globalThis.fetch => |
| 300 | 288 | ((input: RequestInfo | URL, init?: RequestInit) => { |