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"; | 1 | import { |
| 2 | import { FetchHttpClient, HttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 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 | import { expect, layer } from "@effect/vitest"; | 15 | import { expect, layer } from "@effect/vitest"; |
| 4 | import { Effect, Layer, Schema } from "effect"; | 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 | const SourceResponse = Schema.Struct({ source: Schema.String }); | 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 | const OrgGroup = HttpApiGroup.make("org").add( | 24 | const OrgGroup = HttpApiGroup.make("org").add( |
| 17 | HttpApiEndpoint.get("ping", "/org/ping", { success: SourceResponse }), | 25 | HttpApiEndpoint.get("ping", "/org/ping", { success: SourceResponse }), |
| 18 | ); | 26 | ); |
| 19 | const OrgTestApi = HttpApi.make("orgApi").add(OrgGroup); | 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 | const AuthGroup = HttpApiGroup.make("auth").add( | 29 | const AuthGroup = HttpApiGroup.make("auth").add( |
| 25 | HttpApiEndpoint.get("me", "/auth/me", { success: SourceResponse }), | 30 | HttpApiEndpoint.get("me", "/auth/me", { success: SourceResponse }), |
| 26 | ); | 31 | ); |
| 27 | const AuthApi = HttpApi.make("authApi").add(AuthGroup); | 32 | const AuthTestApi = HttpApi.make("authApi").add(AuthGroup); |
| 28 | const AuthHandlers = HttpApiBuilder.group(AuthApi, "auth", (handlers) => | ||
| 29 | handlers.handle("me", () => Effect.succeed({ source: "auth" })), | ||
| 30 | ); | ||
| 31 | 33 | ||
| 32 | const ProtectedGroup = HttpApiGroup.make("protected") | 34 | const ProtectedGroup = HttpApiGroup.make("protected") |
| 33 | .add(HttpApiEndpoint.get("scope", "/scope", { success: SourceResponse })) | 35 | .add(HttpApiEndpoint.get("scope", "/scope", { success: SourceResponse })) |
| @@ -43,93 +45,104 @@ const ProtectedGroup = HttpApiGroup.make("protected") · 9 unmodified lines | |||
| 43 | success: SourceResponse, | 45 | success: SourceResponse, |
| 44 | }), | 46 | }), |
| 45 | ); | 47 | ); |
| 46 | const ProtectedApi = HttpApi.make("protectedApi").add(ProtectedGroup); | 48 | const ProtectedTestApi = 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 | ); | ||
| 53 | 49 | ||
| 54 | const toHttpApp = ( | 50 | // --------------------------------------------------------------------------- |
| 55 | apiLayer: Parameters<typeof HttpRouter.toWebHandler>[0], | 51 | // Stub handlers |
| 56 | ) => | 52 | // --------------------------------------------------------------------------- |
| 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 | ); | ||
| 72 | 53 | ||
| 73 | const AuthTestApp = toHttpApp( | 54 | const OrgTestHandlers = HttpApiBuilder.group(OrgTestApi, "org", (handlers) => |
| 74 | HttpApiBuilder.layer(AuthApi).pipe( | 55 | handlers.handle("ping", () => Effect.succeed({ source: "org" })), |
| 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], | ||
| 79 | ); | 56 | ); |
| 80 | 57 | ||
| 81 | const ProtectedBaseTestApp = toHttpApp( | 58 | const AuthHandlers = HttpApiBuilder.group(AuthTestApi, "auth", (handlers) => |
| 82 | HttpApiBuilder.layer(ProtectedApi).pipe( | 59 | handlers.handle("me", () => Effect.succeed({ source: "auth" })), |
| 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], | ||
| 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: { | 69 | // --------------------------------------------------------------------------- |
| 92 | mode: ProtectedMode; | 70 | // Per-test mode switch — controls a router-level gate that mirrors the prod |
| 93 | } = { | 71 | // `ExecutionStackMiddleware`'s short-circuit branches without standing up |
| 94 | mode: "ok", | 72 | // the full WorkOS / executor stack. |
| 95 | }; | 73 | // --------------------------------------------------------------------------- |
| 96 | 74 | ||
| 75 | type ProtectedMode = "ok" | "none" | "error" | "bad-status"; | ||
| 76 | const testState: { mode: ProtectedMode } = { mode: "ok" }; | ||
| 97 | const resetState = () => { | 77 | const resetState = () => { |
| 98 | testState.mode = "ok"; | 78 | testState.mode = "ok"; |
| 99 | }; | 79 | }; |
| 100 | 80 | ||
| 101 | const ProtectedTestApp = Effect.gen(function* () { | 81 | // `Effect.suspend` so `testState.mode` is read per request — the |
| 102 | if (testState.mode === "none") { | 82 | // middleware function itself runs once at addAll time, wrapping each |
| 103 | return HttpServerResponse.jsonUnsafe( | 83 | // route's handler. Without `suspend`, only the build-time mode ("ok") |
| 104 | { error: "No organization in session", code: "no_organization" }, | 84 | // would be observed. |
| 105 | { status: 403 }, | 85 | const TestProtectedGate = HttpRouter.middleware()((httpEffect) => |
| 106 | ); | 86 | Effect.suspend(() => { |
| 107 | } | 87 | if (testState.mode === "none") { |
| 108 | if (testState.mode === "error") { | 88 | return Effect.succeed( |
| 109 | return HttpServerResponse.jsonUnsafe({ error: "boom" }, { status: 500 }); | 89 | HttpServerResponse.jsonUnsafe( |
| 110 | } | 90 | { error: "No organization in session", code: "no_organization" }, |
| 111 | if (testState.mode === "bad-status") { | 91 | { status: 403 }, |
| 112 | return HttpServerResponse.jsonUnsafe({ source: "protected" }, { status: 400 }); | 92 | ), |
| 113 | } | 93 | ); |
| 114 | return yield* ProtectedBaseTestApp; | 94 | } |
| 115 | }); | 95 | if (testState.mode === "error") { |
| 116 | 96 | return Effect.succeed( | |
| 117 | const TestRequestHandlersLive = Layer.mergeAll( | 97 | HttpServerResponse.jsonUnsafe({ error: "boom" }, { status: 500 }), |
| 118 | Layer.succeed(OrgRequestHandlerService)({ app: OrgTestApp }), | 98 | ); |
| 119 | Layer.succeed(NonProtectedRequestHandlerService)({ app: AuthTestApp }), | 99 | } |
| 120 | Layer.succeed(AutumnRequestHandlerService)({ | 100 | if (testState.mode === "bad-status") { |
| 121 | app: Effect.succeed(HttpServerResponse.jsonUnsafe({ source: "autumn" })), | 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( | 122 | const AutumnTestRoutesLive = HttpRouter.add( |
| 127 | Effect.map( | 123 | "*", |
| 128 | Effect.provide(ApiRequestHandler, TestRequestHandlersLive), | 124 | "/autumn/*", |
| 129 | (app) => HttpEffect.toWebHandler(app), | 125 | Effect.succeed(HttpServerResponse.jsonUnsafe({ source: "autumn" })), |
| 130 | ), | ||
| 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 | const TestApi = HttpApi.make("testApi") | 146 | const TestApi = HttpApi.make("testApi") |
| 134 | .add(OrgGroup) | 147 | .add(OrgGroup) |
| 135 | .add(AuthGroup) | 148 | .add(AuthGroup) |
| @@ -140,9 +153,6 @@ const TestApi = HttpApi.make("testApi") · 4 unmodified lines | |||
| 140 | ) | 153 | ) |
| 141 | .add(ProtectedGroup); | 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 | const TEST_BASE_URL = "http://test.local"; | 156 | const TEST_BASE_URL = "http://test.local"; |
| 147 | const fetchViaHandler: typeof globalThis.fetch = (input, init) => | 157 | const fetchViaHandler: typeof globalThis.fetch = (input, init) => |
| 148 | requestHandler(input instanceof Request ? input : new Request(input, init)); | 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 | Effect.gen(function* () { | 204 | Effect.gen(function* () { |
| 195 | resetState(); | 205 | resetState(); |
| 196 | testState.mode = "none"; | 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 | Effect.gen(function* () { | 228 | Effect.gen(function* () { |
| 219 | resetState(); | 229 | resetState(); |
| 220 | const client = yield* getClient(); | 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 | Effect.gen(function* () { | 247 | Effect.gen(function* () { |
| 238 | resetState(); | 248 | resetState(); |
| 239 | testState.mode = "error"; | 249 | testState.mode = "error"; |
| @@ -1,26 +1,5 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | 1 | import { HttpRouter } from "effect/unstable/http"; |
| 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"; | ||
| 13 | 2 | ||
| 14 | const ApiRequestHandlersLive = Layer.mergeAll( | 3 | import { ApiLive } from "./api/router"; |
| 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 | ); | ||
| 20 | 4 | ||
| 21 | export const handleApiRequest = Effect.runSync( | 5 | export const handleApiRequest = HttpRouter.toWebHandler(ApiLive).handler; |
| 22 | Effect.map( | ||
| 23 | Effect.provide(ApiRequestHandler, ApiRequestHandlersLive), | ||
| 24 | (app) => HttpEffect.toWebHandler(app), | ||
| 25 | ), | ||
| 26 | ); | ||
| @@ -1,13 +1,20 @@ | |||
| 1 | import { env } from "cloudflare:workers"; | 1 | import { env } from "cloudflare:workers"; |
| 2 | import { Effect } from "effect"; | 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 | import { autumnHandler } from "autumn-js/backend"; | 8 | import { autumnHandler } from "autumn-js/backend"; |
| 5 | 9 | ||
| 6 | import { WorkOSAuth } from "../auth/workos"; | 10 | import { WorkOSAuth } from "../auth/workos"; |
| 7 | import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; | 11 | import { |
| 8 | import { SharedServices } from "./layers"; | 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 | const request = yield* HttpServerRequest.HttpServerRequest; | 18 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 12 | const webRequest = yield* Effect.mapError( | 19 | const webRequest = yield* Effect.mapError( |
| 13 | HttpServerRequest.toWeb(request), | 20 | HttpServerRequest.toWeb(request), |
| @@ -78,7 +85,6 @@ export const AutumnApiApp = Effect.gen(function* () { · 64 unmodified lines | |||
| 78 | 85 | ||
| 79 | return HttpServerResponse.jsonUnsafe(response, { status: statusCode }); | 86 | return HttpServerResponse.jsonUnsafe(response, { status: statusCode }); |
| 80 | }).pipe( | 87 | }).pipe( |
| 81 | Effect.provide(SharedServices), | ||
| 82 | Effect.catchCause((err) => { | 88 | Effect.catchCause((err) => { |
| 83 | if (isServerError(err)) { | 89 | if (isServerError(err)) { |
| 84 | console.error("[autumn] request failed:", err instanceof Error ? err.stack : err); | 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 | return Effect.succeed(toErrorServerResponse(err)); | 92 | return Effect.succeed(toErrorServerResponse(err)); |
| 87 | }), | 93 | }), |
| 88 | ); | 94 | ); |
| 95 | |||
| 96 | export const AutumnRoutesLive = HttpRouter.add("*", "/autumn/*", handler); | ||
| @@ -1,12 +1,29 @@ | |||
| 1 | import * as Sentry from "@sentry/cloudflare"; | 1 | import * as Sentry from "@sentry/cloudflare"; |
| 2 | import { Data } from "effect"; | 2 | import { Data, Effect } from "effect"; |
| 3 | import { HttpServerResponse } from "effect/unstable/http"; | 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 | export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{ | 12 | export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{ |
| 6 | readonly status: number; | 13 | readonly status: number; |
| 7 | readonly code: string; | 14 | readonly code: string; |
| 8 | readonly message: string; | 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 | const toHttpResponseError = (error: unknown): HttpResponseError => | 28 | const toHttpResponseError = (error: unknown): HttpResponseError => |
| 12 | error instanceof HttpResponseError | 29 | error instanceof HttpResponseError |
| @@ -1,14 +1,7 @@ | |||
| 1 | import { HttpApiBuilder } from "effect/unstable/httpapi"; | 1 | import { HttpApiBuilder } from "effect/unstable/httpapi"; |
| 2 | import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"; | 2 | import { HttpServer } from "effect/unstable/http"; |
| 3 | import { Effect, Layer } from "effect"; | 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 | import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; | 5 | import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; |
| 13 | import { UserStoreService } from "../auth/context"; | 6 | import { UserStoreService } from "../auth/context"; |
| 14 | import { | 7 | import { |
| @@ -20,18 +13,15 @@ import { DbService } from "../services/db"; · 5 unmodified lines | |||
| 20 | import { TelemetryLive } from "../services/telemetry"; | 13 | import { TelemetryLive } from "../services/telemetry"; |
| 21 | import { OrgHttpApi } from "../org/compose"; | 14 | import { OrgHttpApi } from "../org/compose"; |
| 22 | import { OrgHandlers } from "../org/handlers"; | 15 | import { OrgHandlers } from "../org/handlers"; |
| 23 | import { ErrorCaptureLive } from "../observability"; | ||
| 24 | 16 | ||
| 25 | import { CoreSharedServices } from "./core-shared-services"; | 17 | import { CoreSharedServices } from "./core-shared-services"; |
| 18 | import { ProtectedCloudApi, RouterConfig } from "./protected-layers"; | ||
| 26 | 19 | ||
| 27 | export { CoreSharedServices }; | 20 | export { |
| 28 | 21 | CoreSharedServices, | |
| 29 | export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) | 22 | ProtectedCloudApi, |
| 30 | .add(McpGroup) | 23 | RouterConfig, |
| 31 | .add(GraphqlGroup) | 24 | }; |
| 32 | .middleware(OrgAuth); | ||
| 33 | |||
| 34 | const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); | ||
| 35 | 25 | ||
| 36 | const DbLive = DbService.Live; | 26 | const DbLive = DbService.Live; |
| 37 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); | 27 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); |
| @@ -44,47 +34,16 @@ export const SharedServices = Layer.mergeAll( · 6 unmodified lines | |||
| 44 | TelemetryLive, | 34 | TelemetryLive, |
| 45 | ); | 35 | ); |
| 46 | 36 | ||
| 47 | export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); | 37 | // Routes that don't require an authenticated org session — login, |
| 48 | 38 | // callbacks, etc. Mounts at the paths declared inside `NonProtectedApi`. | |
| 49 | export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( | 39 | export const NonProtectedApiLive = HttpApiBuilder.layer(NonProtectedApi).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( | ||
| 64 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), | 40 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), |
| 65 | Layer.provideMerge(SessionAuthLive), | 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 | Layer.provide(OrgHandlers), | 47 | Layer.provide(OrgHandlers), |
| 70 | Layer.provideMerge(OrgAuthLive), | 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 | import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; | 16 | import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; |
| 17 | import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api"; | 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 | import { UserStoreService } from "../auth/context"; | 19 | import { UserStoreService } from "../auth/context"; |
| 22 | import { WorkOSAuth } from "../auth/workos"; | 20 | import { WorkOSAuth } from "../auth/workos"; |
| 23 | import { AutumnService } from "../services/autumn"; | 21 | import { AutumnService } from "../services/autumn"; |
| 24 | import { DbService } from "../services/db"; | 22 | import { DbService } from "../services/db"; |
| 25 | import { ErrorCaptureLive } from "../observability"; | 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 | export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) | 33 | export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) |
| 28 | .add(McpGroup) | 34 | .add(McpGroup) |
| 29 | .add(GraphqlGroup) | 35 | .add(GraphqlGroup); |
| 30 | .middleware(OrgAuth); | ||
| 31 | 36 | ||
| 32 | const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); | 37 | const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); |
| 33 | 38 | ||
| @@ -44,9 +49,9 @@ export const SharedServices = Layer.mergeAll( · 10 unmodified lines | |||
| 44 | 49 | ||
| 45 | export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); | 50 | export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); |
| 46 | 51 | ||
| 47 | // Every handler the ProtectedCloudApi routes to, minus auth. The test | 52 | // Every handler the ProtectedCloudApi routes to. The test harness builds |
| 48 | // harness builds its own api-live by merging this with a fake OrgAuth | 53 | // its own api-live by merging this with its own per-request middleware |
| 49 | // layer; prod merges it with OrgAuthLive below. | 54 | // fakes; prod uses `ProtectedCloudApiLive` below. |
| 50 | export const ProtectedCloudApiHandlers = Layer.mergeAll( | 55 | export const ProtectedCloudApiHandlers = Layer.mergeAll( |
| 51 | CoreHandlers, | 56 | CoreHandlers, |
| 52 | OpenApiHandlers, | 57 | OpenApiHandlers, |
| @@ -59,8 +64,6 @@ export const ProtectedCloudApiHandlers = Layer.mergeAll( · 6 unmodified lines | |||
| 59 | // InternalError(traceId)`) AND the observability middleware's defect | 64 | // InternalError(traceId)`) AND the observability middleware's defect |
| 60 | // catchall both see the same Sentry-backed implementation. | 65 | // catchall both see the same Sentry-backed implementation. |
| 61 | export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( | 66 | export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( |
| 62 | Layer.provide( | 67 | Layer.provide(Layer.mergeAll(ProtectedCloudApiHandlers, ObservabilityLive)), |
| 63 | Layer.mergeAll(ProtectedCloudApiHandlers, OrgAuthLive, ObservabilityLive), | ||
| 64 | ), | ||
| 65 | Layer.provide(ErrorCaptureLive), | 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 | import { HttpApiSwagger } from "effect/unstable/httpapi"; | 5 | import { HttpApiSwagger } from "effect/unstable/httpapi"; |
| 2 | import { | 6 | import { |
| 3 | HttpMiddleware, | ||
| 4 | HttpRouter, | 7 | HttpRouter, |
| 5 | HttpServerRequest, | 8 | HttpServerRequest, |
| 6 | } from "effect/unstable/http"; | 9 | } from "effect/unstable/http"; |
| 7 | import { Effect, Layer } from "effect"; | 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 | import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; | 16 | import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; |
| 11 | import { McpExtensionService } from "@executor-js/plugin-mcp/api"; | 17 | import { McpExtensionService } from "@executor-js/plugin-mcp/api"; |
| 12 | import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; | 18 | import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; |
| 13 | 19 | ||
| 20 | import { AuthContext } from "../auth/middleware"; | ||
| 14 | import { authorizeOrganization } from "../auth/authorize-organization"; | 21 | import { authorizeOrganization } from "../auth/authorize-organization"; |
| 22 | import { UserStoreService } from "../auth/context"; | ||
| 15 | import { WorkOSAuth } from "../auth/workos"; | 23 | import { WorkOSAuth } from "../auth/workos"; |
| 24 | import { AutumnService } from "../services/autumn"; | ||
| 25 | import { DbService } from "../services/db"; | ||
| 16 | import { makeExecutionStack } from "../services/execution-stack"; | 26 | import { makeExecutionStack } from "../services/execution-stack"; |
| 17 | import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; | 27 | import { HttpResponseError } from "./error-response"; |
| 18 | import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers"; | 28 | import { |
| 19 | 29 | ProtectedCloudApi, | |
| 20 | const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => | 30 | ProtectedCloudApiLive, |
| 21 | Effect.gen(function* () { | 31 | RouterConfig, |
| 22 | const webRequest = yield* Effect.mapError( | 32 | } from "./protected-layers"; |
| 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 | }); | ||
| 39 | 33 | ||
| 40 | const createProtectedApp = ( | 34 | // One `HttpRouter` middleware that: |
| 41 | userId: string, | 35 | // 1. authenticates the WorkOS sealed session, |
| 42 | organizationId: string, | 36 | // 2. verifies live org membership (closes the JWT-cache gap — see |
| 43 | organizationName: string, | 37 | // `auth/authorize-organization.ts`), |
| 44 | ) => | 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 | Effect.gen(function* () { | 65 | Effect.gen(function* () { |
| 46 | const { executor, engine } = yield* makeExecutionStack( | 66 | const context = yield* Effect.context< |
| 47 | userId, | 67 | WorkOSAuth | UserStoreService | AutumnService | DbService |
| 48 | organizationId, | 68 | >(); |
| 49 | organizationName, | 69 | return (httpEffect) => |
| 50 | ); | 70 | Effect.gen(function* () { |
| 51 | 71 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 52 | const requestServices = Layer.mergeAll( | 72 | const webRequest = yield* HttpServerRequest.toWeb(request); |
| 53 | Layer.succeed(ExecutorService)(executor), | 73 | const workos = yield* WorkOSAuth; |
| 54 | Layer.succeed(ExecutionEngineService)(engine), | 74 | const session = yield* workos.authenticateRequest(webRequest); |
| 55 | Layer.succeed(OpenApiExtensionService)(executor.openapi), | 75 | if (!session || !session.organizationId) { |
| 56 | Layer.succeed(McpExtensionService)(executor.mcp), | 76 | return yield* new HttpResponseError({ |
| 57 | Layer.succeed(GraphqlExtensionService)(executor.graphql), | 77 | status: 403, |
| 58 | ); | 78 | code: "no_organization", |
| 59 | 79 | message: "No organization in session", | |
| 60 | const protectedApiLayer = ProtectedCloudApiLive.pipe( | 80 | }); |
| 61 | Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), | 81 | } |
| 62 | Layer.provideMerge(RouterConfig), | 82 | const org = yield* authorizeOrganization(session.userId, session.organizationId); |
| 63 | ); | 83 | if (!org) { |
| 64 | 84 | return yield* new HttpResponseError({ | |
| 65 | return yield* HttpRouter.toHttpEffect(protectedApiLayer).pipe( | 85 | status: 403, |
| 66 | Effect.flatMap(HttpMiddleware.logger), | 86 | code: "no_organization", |
| 67 | Effect.provide(requestServices), | 87 | message: "No organization in session", |
| 68 | ); | 88 | }); |
| 69 | }); | 89 | } |
| 70 | 90 | const auth = AuthContext.of({ | |
| 71 | export const ProtectedApiApp = Effect.gen(function* () { | 91 | accountId: session.userId, |
| 72 | const request = yield* HttpServerRequest.HttpServerRequest; | 92 | organizationId: org.id, |
| 73 | const session = yield* lookupOrgForRequest(request); | 93 | email: session.email, |
| 74 | if (!session) { | 94 | name: `${session.firstName ?? ""} ${session.lastName ?? ""}`.trim() || null, |
| 75 | return yield* Effect.fail( | 95 | avatarUrl: session.avatarUrl ?? null, |
| 76 | new HttpResponseError({ | 96 | }); |
| 77 | status: 403, | 97 | const { executor, engine } = yield* makeExecutionStack(auth.accountId, org.id, org.name); |
| 78 | code: "no_organization", | 98 | return yield* httpEffect.pipe( |
| 79 | message: "No organization in session", | 99 | Effect.provideService(AuthContext, auth), |
| 80 | }), | 100 | Effect.provideService(ExecutorService, executor), |
| 81 | ); | 101 | Effect.provideService(ExecutionEngineService, engine), |
| 82 | } | 102 | Effect.provideService(OpenApiExtensionService, executor.openapi), |
| 83 | 103 | Effect.provideService(McpExtensionService, executor.mcp), | |
| 84 | return yield* createProtectedApp(session.userId, session.org.id, session.org.name); | 104 | Effect.provideService(GraphqlExtensionService, executor.graphql), |
| 85 | }).pipe( | 105 | ); |
| 86 | Effect.provide(SharedServices), | 106 | }).pipe(Effect.provideContext(context)); |
| 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)); | ||
| 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"; | 1 | import { Layer } from "effect"; |
| 2 | import { Context, Effect, Scope } from "effect"; | 2 | |
| 3 | 3 | import { AutumnRoutesLive } from "./autumn"; | |
| 4 | export type ApiRouteApp = Effect.Effect< | 4 | import { |
| 5 | HttpServerResponse.HttpServerResponse, | 5 | NonProtectedApiLive, |
| 6 | unknown, | 6 | OrgApiLive, |
| 7 | HttpServerRequest.HttpServerRequest | Scope.Scope | 7 | RouterConfig, |
| 8 | >; | 8 | SharedServices, |
| 9 | 9 | } from "./layers"; | |
| 10 | type RequestAppService = { | 10 | import { ProtectedApiLive } from "./protected"; |
| 11 | readonly app: ApiRouteApp; | 11 | |
| 12 | }; | 12 | // One router. Each sub-API contributes its routes via `HttpApiBuilder.layer`, |
| 13 | 13 | // which calls `HttpRouter.use(...)` under the hood. Autumn's catch-all proxy | |
| 14 | export class OrgRequestHandlerService extends Context.Service<OrgRequestHandlerService, RequestAppService>()( | 14 | // is added as a plain `HttpRouter.add` route. They all merge into the same |
| 15 | "@executor-js/cloud/OrgRequestHandlerService", | 15 | // routing table; there is no outer-then-inner router stacking. |
| 16 | ) {} | 16 | export const ApiLive = Layer.mergeAll( |
| 17 | 17 | NonProtectedApiLive, | |
| 18 | export class NonProtectedRequestHandlerService extends Context.Service<NonProtectedRequestHandlerService, RequestAppService>()( | 18 | OrgApiLive, |
| 19 | "@executor-js/cloud/NonProtectedRequestHandlerService", | 19 | ProtectedApiLive, |
| 20 | ) {} | 20 | AutumnRoutesLive, |
| 21 | 21 | ).pipe( | |
| 22 | export class AutumnRequestHandlerService extends Context.Service<AutumnRequestHandlerService, RequestAppService>()( | 22 | Layer.provideMerge(RouterConfig), |
| 23 | "@executor-js/cloud/AutumnRequestHandlerService", | 23 | Layer.provideMerge(SharedServices), |
| 24 | ) {} | 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; | ||
| @@ -16,7 +16,12 @@ · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | import { Effect, Layer } from "effect"; | 17 | import { Effect, Layer } from "effect"; |
| 18 | import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi"; | 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 | import { | 26 | import { |
| 22 | ExecutionEngineService, | 27 | ExecutionEngineService, |
| @@ -48,7 +53,7 @@ import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; · 25 unmodified lines | |||
| 48 | import { McpExtensionService } from "@executor-js/plugin-mcp/api"; | 53 | import { McpExtensionService } from "@executor-js/plugin-mcp/api"; |
| 49 | import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; | 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 | import { | 57 | import { |
| 53 | ProtectedCloudApi, | 58 | ProtectedCloudApi, |
| 54 | ProtectedCloudApiHandlers, | 59 | ProtectedCloudApiHandlers, |
| @@ -209,10 +214,27 @@ const createTestScopedExecutor = ( · 154 unmodified lines | |||
| 209 | // HTTP plumbing | 214 | // HTTP plumbing |
| 210 | // --------------------------------------------------------------------------- | 215 | // --------------------------------------------------------------------------- |
| 211 | 216 | ||
| 212 | const FakeOrgAuthLive = Layer.succeed( | 217 | // Test version of the production `ExecutionStackMiddleware` — reads the |
| 213 | OrgAuth, | 218 | // `x-test-org-id` (and optional `x-test-user-id`) header, builds a |
| 214 | { | 219 | // test-scoped executor against the live postgres test db with a fake |
| 215 | cookie: (httpApp) => | 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 | Effect.gen(function* () { | 238 | Effect.gen(function* () { |
| 217 | const request = yield* HttpServerRequest.HttpServerRequest; | 239 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 218 | const orgId = request.headers[TEST_ORG_HEADER]; | 240 | const orgId = request.headers[TEST_ORG_HEADER]; |
| @@ -224,7 +246,13 @@ const FakeOrgAuthLive = Layer.succeed( · 5 unmodified lines | |||
| 224 | typeof userHeader === "string" && userHeader.length > 0 | 246 | typeof userHeader === "string" && userHeader.length > 0 |
| 225 | ? userHeader | 247 | ? userHeader |
| 226 | : defaultUserFor(orgId); | 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 | Effect.provideService( | 256 | Effect.provideService( |
| 229 | AuthContext, | 257 | AuthContext, |
| 230 | AuthContext.of({ | 258 | AuthContext.of({ |
| @@ -235,66 +263,26 @@ const FakeOrgAuthLive = Layer.succeed( · 4 unmodified lines | |||
| 235 | avatarUrl: null, | 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 | }), | 272 | }).pipe(Effect.provideContext(context)); |
| 240 | }, | 273 | }), |
| 241 | ); | 274 | ).layer; |
| 242 | 275 | ||
| 243 | const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( | 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) => | 285 | const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler; |
| 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 | ); | ||
| 298 | 286 | ||
| 299 | export const fetchForOrg = (orgId: string): typeof globalThis.fetch => | 287 | export const fetchForOrg = (orgId: string): typeof globalThis.fetch => |
| 300 | ((input: RequestInfo | URL, init?: RequestInit) => { | 288 | ((input: RequestInfo | URL, init?: RequestInit) => { |