History

Refactor cloud API routing to be Effect v4 native (#468)

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.

Rhys Sullivan committed 6 months ago

Commit 455b68c1 parent d04e28a9 files · +336 −383Browse files

Unified · Split

9 files
  1. apps/cloud/src/api.test.ts +103 −93
  2. apps/cloud/src/api.ts +3 −24
  3. apps/cloud/src/api/autumn.ts +13 −5
  4. apps/cloud/src/api/error-response.ts +20 −3
  5. apps/cloud/src/api/layers.ts +14 −55
  6. apps/cloud/src/api/protected-layers.ts +13 −10
  7. apps/cloud/src/api/protected.ts +96 −75
  8. apps/cloud/src/api/router.ts +24 −56
  9. apps/cloud/src/services/__test-harness__/api-harness.ts +50 −62
apps/cloud/src/api.test.ts+103−93
@@ -1,33 +1,35 @@
1import { HttpApi, HttpApiBuilder, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";1import {
2import { 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";
8import {
9 FetchHttpClient,
10 HttpClient,
11 HttpRouter,
12 HttpServer,
13 HttpServerResponse,
14} from "effect/unstable/http";
3import { expect, layer } from "@effect/vitest";15import { expect, layer } from "@effect/vitest";
4import { Effect, Layer, Schema } from "effect";16import { Effect, Layer, Schema } from "effect";
5 17
6import {
7 ApiRequestHandler,
8 AutumnRequestHandlerService,
9 NonProtectedRequestHandlerService,
10 ProtectedRequestHandlerService,
11 OrgRequestHandlerService,
12} from "./api/router";
13
14const SourceResponse = Schema.Struct({ source: Schema.String });18const SourceResponse = Schema.Struct({ source: Schema.String });
15 19
20// ---------------------------------------------------------------------------
21// Test APIs — mirror the prod paths but with stub handlers.
22// ---------------------------------------------------------------------------
23
16const OrgGroup = HttpApiGroup.make("org").add(24const OrgGroup = HttpApiGroup.make("org").add(
17 HttpApiEndpoint.get("ping", "/org/ping", { success: SourceResponse }),25 HttpApiEndpoint.get("ping", "/org/ping", { success: SourceResponse }),
18);26);
19const OrgTestApi = HttpApi.make("orgApi").add(OrgGroup);27const OrgTestApi = HttpApi.make("orgApi").add(OrgGroup);
20const OrgTestHandlers = HttpApiBuilder.group(OrgTestApi, "org", (handlers) =>
21 handlers.handle("ping", () => Effect.succeed({ source: "org" })),
22);
23 28
24const AuthGroup = HttpApiGroup.make("auth").add(29const AuthGroup = HttpApiGroup.make("auth").add(
25 HttpApiEndpoint.get("me", "/auth/me", { success: SourceResponse }),30 HttpApiEndpoint.get("me", "/auth/me", { success: SourceResponse }),
26);31);
27const AuthApi = HttpApi.make("authApi").add(AuthGroup);32const AuthTestApi = HttpApi.make("authApi").add(AuthGroup);
28const AuthHandlers = HttpApiBuilder.group(AuthApi, "auth", (handlers) =>
29 handlers.handle("me", () => Effect.succeed({ source: "auth" })),
30);
31 33
32const ProtectedGroup = HttpApiGroup.make("protected")34const 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 );
46const ProtectedApi = HttpApi.make("protectedApi").add(ProtectedGroup);48const ProtectedTestApi = HttpApi.make("protectedApi").add(ProtectedGroup);
47const 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
54const 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
65const 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
73const AuthTestApp = toHttpApp(54const 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
81const ProtectedBaseTestApp = toHttpApp(58const 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
89type ProtectedMode = "ok" | "none" | "error" | "bad-status";62const 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
91const 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
75type ProtectedMode = "ok" | "none" | "error" | "bad-status";
76const testState: { mode: ProtectedMode } = { mode: "ok" };
97const resetState = () => {77const resetState = () => {
98 testState.mode = "ok";78 testState.mode = "ok";
99};79};
100 80
101const 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 },85const 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(
117const 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
113const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 });
114
115const OrgTestLive = HttpApiBuilder.layer(OrgTestApi).pipe(Layer.provide(OrgTestHandlers));
116const AuthTestLive = HttpApiBuilder.layer(AuthTestApi).pipe(Layer.provide(AuthHandlers));
117const ProtectedTestLive = HttpApiBuilder.layer(ProtectedTestApi).pipe(
118 Layer.provide(ProtectedHandlers),
119 Layer.provide(TestProtectedGate),
124);120);
125 121
126const requestHandler = Effect.runSync(122const 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
128const TestApiLive = Layer.mergeAll(
129 OrgTestLive,
130 AuthTestLive,
131 ProtectedTestLive,
132 AutumnTestRoutesLive,
133).pipe(
134 Layer.provideMerge(RouterConfig),
135 Layer.provideMerge(HttpServer.layerServices),
136);
137
138const 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
133const TestApi = HttpApi.make("testApi")146const 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).
146const TEST_BASE_URL = "http://test.local";156const TEST_BASE_URL = "http://test.local";
147const fetchViaHandler: typeof globalThis.fetch = (input, init) =>157const 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";
apps/cloud/src/api.ts+3−24
@@ -1,26 +1,5 @@
1import { Effect, Layer } from "effect";1import { HttpRouter } from "effect/unstable/http";
2import { HttpEffect } from "effect/unstable/http";
3import { AutumnApiApp } from "./api/autumn";
4import { NonProtectedApiApp, OrgApiApp } from "./api/layers";
5import { ProtectedApiApp } from "./api/protected";
6import {
7 ApiRequestHandler,
8 AutumnRequestHandlerService,
9 NonProtectedRequestHandlerService,
10 ProtectedRequestHandlerService,
11 OrgRequestHandlerService,
12} from "./api/router";
13 2
14const ApiRequestHandlersLive = Layer.mergeAll(3import { 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
21export const handleApiRequest = Effect.runSync(5export const handleApiRequest = HttpRouter.toWebHandler(ApiLive).handler;
22 Effect.map(
23 Effect.provide(ApiRequestHandler, ApiRequestHandlersLive),
24 (app) => HttpEffect.toWebHandler(app),
25 ),
26);
apps/cloud/src/api/autumn.ts+13−5
@@ -1,13 +1,20 @@
1import { env } from "cloudflare:workers";1import { env } from "cloudflare:workers";
2import { Effect } from "effect";2import { Effect } from "effect";
3import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";3import {
4 HttpRouter,
5 HttpServerRequest,
6 HttpServerResponse,
7} from "effect/unstable/http";
4import { autumnHandler } from "autumn-js/backend";8import { autumnHandler } from "autumn-js/backend";
5 9
6import { WorkOSAuth } from "../auth/workos";10import { WorkOSAuth } from "../auth/workos";
7import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";11import {
8import { SharedServices } from "./layers";12 HttpResponseError,
13 isServerError,
14 toErrorServerResponse,
15} from "./error-response";
9 16
10export const AutumnApiApp = Effect.gen(function* () {17const 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
96export const AutumnRoutesLive = HttpRouter.add("*", "/autumn/*", handler);
apps/cloud/src/api/error-response.ts+20−3
@@ -1,12 +1,29 @@
1import * as Sentry from "@sentry/cloudflare";1import * as Sentry from "@sentry/cloudflare";
2import { Data } from "effect";2import { Data, Effect } from "effect";
3import { HttpServerResponse } from "effect/unstable/http";3import {
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.
5export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{12export 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
11const toHttpResponseError = (error: unknown): HttpResponseError =>28const toHttpResponseError = (error: unknown): HttpResponseError =>
12 error instanceof HttpResponseError29 error instanceof HttpResponseError
apps/cloud/src/api/layers.ts+14−55
@@ -1,14 +1,7 @@
1import { HttpApiBuilder } from "effect/unstable/httpapi";1import { HttpApiBuilder } from "effect/unstable/httpapi";
2import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http";2import { HttpServer } from "effect/unstable/http";
3import { Effect, Layer } from "effect";3import { Layer } from "effect";
4 4
5import { CoreExecutorApi, observabilityMiddleware } from "@executor-js/api";
6import { CoreHandlers } from "@executor-js/api/server";
7import { OpenApiGroup, OpenApiHandlers } from "@executor-js/plugin-openapi/api";
8import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api";
9import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api";
10
11import { OrgAuth } from "../auth/middleware";
12import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";5import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";
13import { UserStoreService } from "../auth/context";6import { UserStoreService } from "../auth/context";
14import {7import {
@@ -20,18 +13,15 @@ import { DbService } from "../services/db"; · 5 unmodified lines
20import { TelemetryLive } from "../services/telemetry";13import { TelemetryLive } from "../services/telemetry";
21import { OrgHttpApi } from "../org/compose";14import { OrgHttpApi } from "../org/compose";
22import { OrgHandlers } from "../org/handlers";15import { OrgHandlers } from "../org/handlers";
23import { ErrorCaptureLive } from "../observability";
24 16
25import { CoreSharedServices } from "./core-shared-services";17import { CoreSharedServices } from "./core-shared-services";
18import { ProtectedCloudApi, RouterConfig } from "./protected-layers";
26 19
27export { CoreSharedServices };20export {
28 21 CoreSharedServices,
29export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)22 ProtectedCloudApi,
30 .add(McpGroup)23 RouterConfig,
31 .add(GraphqlGroup)24};
32 .middleware(OrgAuth);
33
34const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);
35 25
36const DbLive = DbService.Live;26const DbLive = DbService.Live;
37const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));27const 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
47export 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`.
49export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe(39export 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
63const 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
68const 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`.
46export 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
73const NonProtectedRequestLayer = NonProtectedApiLive.pipe(
74 Layer.provideMerge(RouterConfig),
75 Layer.provideMerge(HttpServer.layerServices),
76);
77
78const OrgRequestLayer = OrgApiLive.pipe(
79 Layer.provideMerge(RouterConfig),
80);
81
82export const NonProtectedApiApp = Effect.flatMap(
83 HttpRouter.toHttpEffect(NonProtectedRequestLayer),
84 HttpMiddleware.logger,
85).pipe(Effect.provide(SharedServices));
86
87export const OrgApiApp = Effect.flatMap(
88 HttpRouter.toHttpEffect(OrgRequestLayer),
89 HttpMiddleware.logger,
90).pipe(Effect.provide(SharedServices));
apps/cloud/src/api/protected-layers.ts+13−10
@@ -16,18 +16,23 @@ import { OpenApiGroup, OpenApiHandlers } from "@executor-js/plugin-openapi/api"; · 15 unmodified lines
16import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api";16import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api";
17import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api";17import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api";
18 18
19import { OrgAuth } from "../auth/middleware";
20import { OrgAuthLive } from "../auth/middleware-live";
21import { UserStoreService } from "../auth/context";19import { UserStoreService } from "../auth/context";
22import { WorkOSAuth } from "../auth/workos";20import { WorkOSAuth } from "../auth/workos";
23import { AutumnService } from "../services/autumn";21import { AutumnService } from "../services/autumn";
24import { DbService } from "../services/db";22import { DbService } from "../services/db";
25import { ErrorCaptureLive } from "../observability";23import { 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.
27export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)33export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
28 .add(McpGroup)34 .add(McpGroup)
29 .add(GraphqlGroup)35 .add(GraphqlGroup);
30 .middleware(OrgAuth);
31 36
32const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);37const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);
33 38
@@ -44,9 +49,9 @@ export const SharedServices = Layer.mergeAll( · 10 unmodified lines
44 49
45export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 });50export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 });
46 51
47// Every handler the ProtectedCloudApi routes to, minus auth. The test52// Every handler the ProtectedCloudApi routes to. The test harness builds
48// harness builds its own api-live by merging this with a fake OrgAuth53// 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.
50export const ProtectedCloudApiHandlers = Layer.mergeAll(55export 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 defect64// 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.
61export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe(66export 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);
apps/cloud/src/api/protected.ts+96−75
@@ -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
1import { HttpApiSwagger } from "effect/unstable/httpapi";5import { HttpApiSwagger } from "effect/unstable/httpapi";
2import {6import {
3 HttpMiddleware,
4 HttpRouter,7 HttpRouter,
5 HttpServerRequest,8 HttpServerRequest,
6} from "effect/unstable/http";9} from "effect/unstable/http";
7import { Effect, Layer } from "effect";10import { Effect, Layer } from "effect";
8 11
9import { ExecutorService, ExecutionEngineService } from "@executor-js/api/server";12import {
13 ExecutionEngineService,
14 ExecutorService,
15} from "@executor-js/api/server";
10import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api";16import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api";
11import { McpExtensionService } from "@executor-js/plugin-mcp/api";17import { McpExtensionService } from "@executor-js/plugin-mcp/api";
12import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api";18import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api";
13 19
20import { AuthContext } from "../auth/middleware";
14import { authorizeOrganization } from "../auth/authorize-organization";21import { authorizeOrganization } from "../auth/authorize-organization";
22import { UserStoreService } from "../auth/context";
15import { WorkOSAuth } from "../auth/workos";23import { WorkOSAuth } from "../auth/workos";
24import { AutumnService } from "../services/autumn";
25import { DbService } from "../services/db";
16import { makeExecutionStack } from "../services/execution-stack";26import { makeExecutionStack } from "../services/execution-stack";
17import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";27import { HttpResponseError } from "./error-response";
18import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers";28import {
19 29 ProtectedCloudApi,
20const 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
40const 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.
51const 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({
71export 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
110export const ProtectedApiLive = ProtectedCloudApiLive.pipe(
111 Layer.provide(ExecutionStackMiddleware),
112 Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),
113 Layer.provideMerge(RouterConfig),
93);114);
apps/cloud/src/api/router.ts+24−56
@@ -1,56 +1,24 @@
1import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";1import { Layer } from "effect";
2import { Context, Effect, Scope } from "effect";2
3 3import { AutumnRoutesLive } from "./autumn";
4export type ApiRouteApp = Effect.Effect<4import {
5 HttpServerResponse.HttpServerResponse,5 NonProtectedApiLive,
6 unknown,6 OrgApiLive,
7 HttpServerRequest.HttpServerRequest | Scope.Scope7 RouterConfig,
8>;8 SharedServices,
9 9} from "./layers";
10type RequestAppService = {10import { 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
14export 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) {}16export const ApiLive = Layer.mergeAll(
17 17 NonProtectedApiLive,
18export class NonProtectedRequestHandlerService extends Context.Service<NonProtectedRequestHandlerService, RequestAppService>()(18 OrgApiLive,
19 "@executor-js/cloud/NonProtectedRequestHandlerService",19 ProtectedApiLive,
20) {}20 AutumnRoutesLive,
21 21).pipe(
22export class AutumnRequestHandlerService extends Context.Service<AutumnRequestHandlerService, RequestAppService>()(22 Layer.provideMerge(RouterConfig),
23 "@executor-js/cloud/AutumnRequestHandlerService",23 Layer.provideMerge(SharedServices),
24) {}24);
25
26export class ProtectedRequestHandlerService extends Context.Service<ProtectedRequestHandlerService, RequestAppService>()(
27 "@executor-js/cloud/ProtectedRequestHandlerService",
28) {}
29
30export 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
56export const ApiRequestHandler = ApiRouterApp;
apps/cloud/src/services/__test-harness__/api-harness.ts+50−62
@@ -16,7 +16,12 @@ · 15 unmodified lines
16 16
17import { Effect, Layer } from "effect";17import { Effect, Layer } from "effect";
18import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi";18import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi";
19import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";19import {
20 FetchHttpClient,
21 HttpRouter,
22 HttpServer,
23 HttpServerRequest,
24} from "effect/unstable/http";
20 25
21import {26import {
22 ExecutionEngineService,27 ExecutionEngineService,
@@ -48,7 +53,7 @@ import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; · 25 unmodified lines
48import { McpExtensionService } from "@executor-js/plugin-mcp/api";53import { McpExtensionService } from "@executor-js/plugin-mcp/api";
49import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api";54import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api";
50 55
51import { AuthContext, OrgAuth } from "../../auth/middleware";56import { AuthContext } from "../../auth/middleware";
52import {57import {
53 ProtectedCloudApi,58 ProtectedCloudApi,
54 ProtectedCloudApiHandlers,59 ProtectedCloudApiHandlers,
@@ -209,10 +214,27 @@ const createTestScopedExecutor = ( · 154 unmodified lines
209// HTTP plumbing214// HTTP plumbing
210// ---------------------------------------------------------------------------215// ---------------------------------------------------------------------------
211 216
212const 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.
223const 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 > 0246 typeof userHeader === "string" && userHeader.length > 0
225 ? userHeader247 ? 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
243const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe(276const 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
247const buildWebHandlerForScope = (userId: string, orgId: string, orgName: string) =>285const 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
269const 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
292const handler = HttpEffect.toWebHandler(
293 RouterApp.pipe(
294 Effect.provide(DbService.Live),
295 Effect.provide(HttpServer.layerServices),
296 ),
297);
298 286
299export const fetchForOrg = (orgId: string): typeof globalThis.fetch =>287export const fetchForOrg = (orgId: string): typeof globalThis.fetch =>
300 ((input: RequestInfo | URL, init?: RequestInit) => {288 ((input: RequestInfo | URL, init?: RequestInit) => {