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 @@
1−import { HttpApi, HttpApiBuilder, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2−import { FetchHttpClient, HttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
1+import {
2+ HttpApi,
3+ HttpApiBuilder,
4+ HttpApiClient,
5+ HttpApiEndpoint,
6+ HttpApiGroup,
7+} from "effect/unstable/httpapi";
8+import {
9+ FetchHttpClient,
10+ HttpClient,
11+ HttpRouter,
12+ HttpServer,
13+ HttpServerResponse,
14+} from "effect/unstable/http";
315 import { expect, layer } from "@effect/vitest";
416 import { Effect, Layer, Schema } from "effect";
517
6−import {
7− ApiRequestHandler,
8− AutumnRequestHandlerService,
9− NonProtectedRequestHandlerService,
10− ProtectedRequestHandlerService,
11− OrgRequestHandlerService,
12−} from "./api/router";
13−
1418 const SourceResponse = Schema.Struct({ source: Schema.String });
1519
20+// ---------------------------------------------------------------------------
21+// Test APIs — mirror the prod paths but with stub handlers.
22+// ---------------------------------------------------------------------------
23+
1624 const OrgGroup = HttpApiGroup.make("org").add(
1725 HttpApiEndpoint.get("ping", "/org/ping", { success: SourceResponse }),
1826 );
1927 const OrgTestApi = HttpApi.make("orgApi").add(OrgGroup);
20−const OrgTestHandlers = HttpApiBuilder.group(OrgTestApi, "org", (handlers) =>
21− handlers.handle("ping", () => Effect.succeed({ source: "org" })),
22−);
2328
2429 const AuthGroup = HttpApiGroup.make("auth").add(
2530 HttpApiEndpoint.get("me", "/auth/me", { success: SourceResponse }),
2631 );
27−const AuthApi = HttpApi.make("authApi").add(AuthGroup);
28−const AuthHandlers = HttpApiBuilder.group(AuthApi, "auth", (handlers) =>
29− handlers.handle("me", () => Effect.succeed({ source: "auth" })),
30−);
32+const AuthTestApi = HttpApi.make("authApi").add(AuthGroup);
3133
3234 const ProtectedGroup = HttpApiGroup.make("protected")
3335 .add(HttpApiEndpoint.get("scope", "/scope", { success: SourceResponse }))
@@ -43,93 +45,104 @@ const ProtectedGroup = HttpApiGroup.make("protected") · 9 unmodified lines
4345 success: SourceResponse,
4446 }),
4547 );
46−const ProtectedApi = HttpApi.make("protectedApi").add(ProtectedGroup);
47−const ProtectedHandlers = HttpApiBuilder.group(ProtectedApi, "protected", (handlers) =>
48− handlers
49− .handle("scope", () => Effect.succeed({ source: "protected" }))
50− .handle("sources", ({ params }) => Effect.succeed({ source: params.scopeId }))
51− .handle("resume", () => Effect.succeed({ source: "protected" })),
52−);
48+const ProtectedTestApi = HttpApi.make("protectedApi").add(ProtectedGroup);
5349
54−const toHttpApp = (
55− apiLayer: Parameters<typeof HttpRouter.toWebHandler>[0],
56−) =>
57− Effect.gen(function* () {
58− const request = yield* HttpServerRequest.HttpServerRequest;
59− const webRequest = yield* HttpServerRequest.toWeb(request);
60− const web = HttpRouter.toWebHandler(apiLayer, { disableLogger: true });
61− const response = yield* Effect.promise(() => web.handler(webRequest, undefined));
62− return HttpServerResponse.raw(response, { status: response.status, headers: response.headers });
63− });
64−
65−const OrgTestApp = toHttpApp(
66− HttpApiBuilder.layer(OrgTestApi).pipe(
67− Layer.provide(OrgTestHandlers),
68− Layer.provideMerge(HttpServer.layerServices),
69− Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })),
70− ) as Parameters<typeof HttpRouter.toWebHandler>[0],
71−);
50+// ---------------------------------------------------------------------------
51+// Stub handlers
52+// ---------------------------------------------------------------------------
7253
73−const AuthTestApp = toHttpApp(
74− HttpApiBuilder.layer(AuthApi).pipe(
75− Layer.provide(AuthHandlers),
76− Layer.provideMerge(HttpServer.layerServices),
77− Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })),
78− ) as Parameters<typeof HttpRouter.toWebHandler>[0],
54+const OrgTestHandlers = HttpApiBuilder.group(OrgTestApi, "org", (handlers) =>
55+ handlers.handle("ping", () => Effect.succeed({ source: "org" })),
7956 );
8057
81−const ProtectedBaseTestApp = toHttpApp(
82− HttpApiBuilder.layer(ProtectedApi).pipe(
83− Layer.provide(ProtectedHandlers),
84− Layer.provideMerge(HttpServer.layerServices),
85− Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })),
86− ) as Parameters<typeof HttpRouter.toWebHandler>[0],
58+const AuthHandlers = HttpApiBuilder.group(AuthTestApi, "auth", (handlers) =>
59+ handlers.handle("me", () => Effect.succeed({ source: "auth" })),
8760 );
8861
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+);
9068
91−const testState: {
92− mode: ProtectedMode;
93−} = {
94− mode: "ok",
95−};
69+// ---------------------------------------------------------------------------
70+// Per-test mode switch — controls a router-level gate that mirrors the prod
71+// `ExecutionStackMiddleware`'s short-circuit branches without standing up
72+// the full WorkOS / executor stack.
73+// ---------------------------------------------------------------------------
9674
75+type ProtectedMode = "ok" | "none" | "error" | "bad-status";
76+const testState: { mode: ProtectedMode } = { mode: "ok" };
9777 const resetState = () => {
9878 testState.mode = "ok";
9979 };
10080
101−const ProtectedTestApp = Effect.gen(function* () {
102− if (testState.mode === "none") {
103− return HttpServerResponse.jsonUnsafe(
104− { error: "No organization in session", code: "no_organization" },
105− { status: 403 },
106− );
107− }
108− if (testState.mode === "error") {
109− return HttpServerResponse.jsonUnsafe({ error: "boom" }, { status: 500 });
110− }
111− if (testState.mode === "bad-status") {
112− return HttpServerResponse.jsonUnsafe({ source: "protected" }, { status: 400 });
113− }
114− return yield* ProtectedBaseTestApp;
115−});
116−
117−const TestRequestHandlersLive = Layer.mergeAll(
118− Layer.succeed(OrgRequestHandlerService)({ app: OrgTestApp }),
119− Layer.succeed(NonProtectedRequestHandlerService)({ app: AuthTestApp }),
120− Layer.succeed(AutumnRequestHandlerService)({
121− app: Effect.succeed(HttpServerResponse.jsonUnsafe({ source: "autumn" })),
81+// `Effect.suspend` so `testState.mode` is read per request — the
82+// middleware function itself runs once at addAll time, wrapping each
83+// route's handler. Without `suspend`, only the build-time mode ("ok")
84+// would be observed.
85+const TestProtectedGate = HttpRouter.middleware()((httpEffect) =>
86+ Effect.suspend(() => {
87+ if (testState.mode === "none") {
88+ return Effect.succeed(
89+ HttpServerResponse.jsonUnsafe(
90+ { error: "No organization in session", code: "no_organization" },
91+ { status: 403 },
92+ ),
93+ );
94+ }
95+ if (testState.mode === "error") {
96+ return Effect.succeed(
97+ HttpServerResponse.jsonUnsafe({ error: "boom" }, { status: 500 }),
98+ );
99+ }
100+ if (testState.mode === "bad-status") {
101+ return Effect.succeed(
102+ HttpServerResponse.jsonUnsafe({ source: "protected" }, { status: 400 }),
103+ );
104+ }
105+ return httpEffect;
122106 }),
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),
124120 );
125121
126−const requestHandler = Effect.runSync(
127− Effect.map(
128− Effect.provide(ApiRequestHandler, TestRequestHandlersLive),
129− (app) => HttpEffect.toWebHandler(app),
130− ),
122+const AutumnTestRoutesLive = HttpRouter.add(
123+ "*",
124+ "/autumn/*",
125+ Effect.succeed(HttpServerResponse.jsonUnsafe({ source: "autumn" })),
131126 );
132127
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+
133146 const TestApi = HttpApi.make("testApi")
134147 .add(OrgGroup)
135148 .add(AuthGroup)
@@ -140,9 +153,6 @@ const TestApi = HttpApi.make("testApi") · 4 unmodified lines
140153 )
141154 .add(ProtectedGroup);
142155
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).
146156 const TEST_BASE_URL = "http://test.local";
147157 const fetchViaHandler: typeof globalThis.fetch = (input, init) =>
148158 requestHandler(input instanceof Request ? input : new Request(input, init));
@@ -190,7 +200,7 @@ layer(TestClientLayer)("handleApiRequest", (it) => { · 41 unmodified lines
190200 }),
191201 );
192202
193− it.effect("returns 403 when protected handler returns no organization", () =>
203+ it.effect("returns 403 when protected gate short-circuits with no organization", () =>
194204 Effect.gen(function* () {
195205 resetState();
196206 testState.mode = "none";
@@ -214,7 +224,7 @@ layer(TestClientLayer)("handleApiRequest", (it) => { · 17 unmodified lines
214224 }),
215225 );
216226
217− it.effect("preserves protected path params through the outer router", () =>
227+ it.effect("preserves protected path params", () =>
218228 Effect.gen(function* () {
219229 resetState();
220230 const client = yield* getClient();
@@ -233,7 +243,7 @@ layer(TestClientLayer)("handleApiRequest", (it) => { · 12 unmodified lines
233243 }),
234244 );
235245
236− it.effect("returns 500 JSON when protected request handling throws", () =>
246+ it.effect("returns 500 JSON when protected gate returns 500", () =>
237247 Effect.gen(function* () {
238248 resetState();
239249 testState.mode = "error";
apps/cloud/src/api.ts+3−24
@@ -1,26 +1,5 @@
1−import { Effect, Layer } from "effect";
2−import { HttpEffect } from "effect/unstable/http";
3−import { AutumnApiApp } from "./api/autumn";
4−import { NonProtectedApiApp, OrgApiApp } from "./api/layers";
5−import { ProtectedApiApp } from "./api/protected";
6−import {
7− ApiRequestHandler,
8− AutumnRequestHandlerService,
9− NonProtectedRequestHandlerService,
10− ProtectedRequestHandlerService,
11− OrgRequestHandlerService,
12−} from "./api/router";
1+import { HttpRouter } from "effect/unstable/http";
132
14−const ApiRequestHandlersLive = Layer.mergeAll(
15− Layer.succeed(OrgRequestHandlerService)({ app: OrgApiApp }),
16− Layer.succeed(NonProtectedRequestHandlerService)({ app: NonProtectedApiApp }),
17− Layer.succeed(AutumnRequestHandlerService)({ app: AutumnApiApp }),
18− Layer.succeed(ProtectedRequestHandlerService)({ app: ProtectedApiApp }),
19−);
3+import { ApiLive } from "./api/router";
204
21−export const handleApiRequest = Effect.runSync(
22− Effect.map(
23− Effect.provide(ApiRequestHandler, ApiRequestHandlersLive),
24− (app) => HttpEffect.toWebHandler(app),
25− ),
26−);
5+export const handleApiRequest = HttpRouter.toWebHandler(ApiLive).handler;
apps/cloud/src/api/autumn.ts+13−5
@@ -1,13 +1,20 @@
11 import { env } from "cloudflare:workers";
22 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";
48 import { autumnHandler } from "autumn-js/backend";
59
610 import { WorkOSAuth } from "../auth/workos";
7−import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
8−import { SharedServices } from "./layers";
11+import {
12+ HttpResponseError,
13+ isServerError,
14+ toErrorServerResponse,
15+} from "./error-response";
916
10−export const AutumnApiApp = Effect.gen(function* () {
17+const handler = Effect.gen(function* () {
1118 const request = yield* HttpServerRequest.HttpServerRequest;
1219 const webRequest = yield* Effect.mapError(
1320 HttpServerRequest.toWeb(request),
@@ -78,7 +85,6 @@ export const AutumnApiApp = Effect.gen(function* () { · 64 unmodified lines
7885
7986 return HttpServerResponse.jsonUnsafe(response, { status: statusCode });
8087 }).pipe(
81− Effect.provide(SharedServices),
8288 Effect.catchCause((err) => {
8389 if (isServerError(err)) {
8490 console.error("[autumn] request failed:", err instanceof Error ? err.stack : err);
@@ -86,3 +92,5 @@ export const AutumnApiApp = Effect.gen(function* () { · 1 unmodified line
8692 return Effect.succeed(toErrorServerResponse(err));
8793 }),
8894 );
95+
96+export const AutumnRoutesLive = HttpRouter.add("*", "/autumn/*", handler);
apps/cloud/src/api/error-response.ts+20−3
@@ -1,12 +1,29 @@
11 import * as Sentry from "@sentry/cloudflare";
2−import { Data } from "effect";
3−import { HttpServerResponse } from "effect/unstable/http";
2+import { Data, Effect } from "effect";
3+import {
4+ HttpServerRespondable,
5+ HttpServerResponse,
6+} from "effect/unstable/http";
47
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.
512 export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{
613 readonly status: number;
714 readonly code: string;
815 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+}
1027
1128 const toHttpResponseError = (error: unknown): HttpResponseError =>
1229 error instanceof HttpResponseError
apps/cloud/src/api/layers.ts+14−55
@@ -1,14 +1,7 @@
11 import { HttpApiBuilder } from "effect/unstable/httpapi";
2−import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http";
3−import { Effect, Layer } from "effect";
2+import { HttpServer } from "effect/unstable/http";
3+import { Layer } from "effect";
44
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";
125 import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";
136 import { UserStoreService } from "../auth/context";
147 import {
@@ -20,18 +13,15 @@ import { DbService } from "../services/db"; · 5 unmodified lines
2013 import { TelemetryLive } from "../services/telemetry";
2114 import { OrgHttpApi } from "../org/compose";
2215 import { OrgHandlers } from "../org/handlers";
23−import { ErrorCaptureLive } from "../observability";
2416
2517 import { CoreSharedServices } from "./core-shared-services";
18+import { ProtectedCloudApi, RouterConfig } from "./protected-layers";
2619
27−export { CoreSharedServices };
28−
29−export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
30− .add(McpGroup)
31− .add(GraphqlGroup)
32− .middleware(OrgAuth);
33−
34−const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);
20+export {
21+ CoreSharedServices,
22+ ProtectedCloudApi,
23+ RouterConfig,
24+};
3525
3626 const DbLive = DbService.Live;
3727 const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
@@ -44,47 +34,16 @@ export const SharedServices = Layer.mergeAll( · 6 unmodified lines
4434 TelemetryLive,
4535 );
4636
47−export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 });
48−
49−export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe(
50− Layer.provide(
51− Layer.mergeAll(
52− CoreHandlers,
53− OpenApiHandlers,
54− McpHandlers,
55− GraphqlHandlers,
56− OrgAuthLive,
57− ObservabilityLive,
58− ),
59− ),
60− Layer.provide(ErrorCaptureLive),
61−);
62−
63−const NonProtectedApiLive = HttpApiBuilder.layer(NonProtectedApi).pipe(
37+// Routes that don't require an authenticated org session — login,
38+// callbacks, etc. Mounts at the paths declared inside `NonProtectedApi`.
39+export const NonProtectedApiLive = HttpApiBuilder.layer(NonProtectedApi).pipe(
6440 Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
6541 Layer.provideMerge(SessionAuthLive),
6642 );
6743
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(
6947 Layer.provide(OrgHandlers),
7048 Layer.provideMerge(OrgAuthLive),
7149 );
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));
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
1616 import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api";
1717 import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api";
1818
19−import { OrgAuth } from "../auth/middleware";
20−import { OrgAuthLive } from "../auth/middleware-live";
2119 import { UserStoreService } from "../auth/context";
2220 import { WorkOSAuth } from "../auth/workos";
2321 import { AutumnService } from "../services/autumn";
2422 import { DbService } from "../services/db";
2523 import { ErrorCaptureLive } from "../observability";
2624
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.
2733 export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
2834 .add(McpGroup)
29− .add(GraphqlGroup)
30− .middleware(OrgAuth);
35+ .add(GraphqlGroup);
3136
3237 const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);
3338
@@ -44,9 +49,9 @@ export const SharedServices = Layer.mergeAll( · 10 unmodified lines
4449
4550 export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 });
4651
47−// Every handler the ProtectedCloudApi routes to, minus auth. The test
48−// harness builds its own api-live by merging this with a fake OrgAuth
49−// layer; prod merges it with OrgAuthLive below.
52+// Every handler the ProtectedCloudApi routes to. The test harness builds
53+// its own api-live by merging this with its own per-request middleware
54+// fakes; prod uses `ProtectedCloudApiLive` below.
5055 export const ProtectedCloudApiHandlers = Layer.mergeAll(
5156 CoreHandlers,
5257 OpenApiHandlers,
@@ -59,8 +64,6 @@ export const ProtectedCloudApiHandlers = Layer.mergeAll( · 6 unmodified lines
5964 // InternalError(traceId)`) AND the observability middleware's defect
6065 // catchall both see the same Sentry-backed implementation.
6166 export const ProtectedCloudApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe(
62− Layer.provide(
63− Layer.mergeAll(ProtectedCloudApiHandlers, OrgAuthLive, ObservabilityLive),
64− ),
67+ Layer.provide(Layer.mergeAll(ProtectedCloudApiHandlers, ObservabilityLive)),
6568 Layer.provide(ErrorCaptureLive),
6669 );
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+
15 import { HttpApiSwagger } from "effect/unstable/httpapi";
26 import {
3− HttpMiddleware,
47 HttpRouter,
58 HttpServerRequest,
69 } from "effect/unstable/http";
710 import { Effect, Layer } from "effect";
811
9−import { ExecutorService, ExecutionEngineService } from "@executor-js/api/server";
12+import {
13+ ExecutionEngineService,
14+ ExecutorService,
15+} from "@executor-js/api/server";
1016 import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api";
1117 import { McpExtensionService } from "@executor-js/plugin-mcp/api";
1218 import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api";
1319
20+import { AuthContext } from "../auth/middleware";
1421 import { authorizeOrganization } from "../auth/authorize-organization";
22+import { UserStoreService } from "../auth/context";
1523 import { WorkOSAuth } from "../auth/workos";
24+import { AutumnService } from "../services/autumn";
25+import { DbService } from "../services/db";
1626 import { makeExecutionStack } from "../services/execution-stack";
17−import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
18−import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers";
19−
20−const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) =>
21− Effect.gen(function* () {
22− const webRequest = yield* Effect.mapError(
23− HttpServerRequest.toWeb(request),
24− () =>
25− new HttpResponseError({
26− status: 500,
27− code: "invalid_request",
28− message: "Invalid request",
29− }),
30− );
31− const workos = yield* WorkOSAuth;
32− const session = yield* workos.authenticateRequest(webRequest);
33− if (!session || !session.organizationId) return null;
34−
35− const org = yield* authorizeOrganization(session.userId, session.organizationId);
36− if (!org) return null;
37− return { org, userId: session.userId };
38− });
27+import { HttpResponseError } from "./error-response";
28+import {
29+ ProtectedCloudApi,
30+ ProtectedCloudApiLive,
31+ RouterConfig,
32+} from "./protected-layers";
3933
40−const createProtectedApp = (
41− userId: string,
42− organizationId: string,
43− organizationName: string,
44−) =>
34+// One `HttpRouter` middleware that:
35+// 1. authenticates the WorkOS sealed session,
36+// 2. verifies live org membership (closes the JWT-cache gap — see
37+// `auth/authorize-organization.ts`),
38+// 3. resolves the org name,
39+// 4. builds the per-request executor + engine,
40+// 5. provides `AuthContext` + the execution-stack services to the handler.
41+//
42+// Replaces both the old outer `Effect.gen` in this file (which did its own
43+// WorkOS lookup) and the per-route `OrgAuth` HttpApiMiddleware (which did
44+// a second one).
45+//
46+// Errors are NOT caught here: failures propagate as typed errors and are
47+// rendered to a JSON response by the framework's `Respondable` pipeline
48+// (see `HttpResponseError` in `./error-response.ts`). Letting `unhandled`
49+// pass through is what satisfies `HttpRouter.middleware`'s brand check
50+// without any type casts.
51+const ExecutionStackMiddleware = HttpRouter.middleware<{
52+ provides:
53+ | AuthContext
54+ | ExecutorService
55+ | ExecutionEngineService
56+ | OpenApiExtensionService
57+ | McpExtensionService
58+ | GraphqlExtensionService;
59+}>()(
60+ // Layer-time setup — capture the long-lived services in a closure so
61+ // the per-request function only needs `HttpRouter`-Provided context.
62+ // That collapses the middleware's `requires` to `never`, giving us a
63+ // real `.layer` (instead of the "Need to .combine(...)" type-error
64+ // sentinel that fires when `requires` leaks to non-never).
4565 Effect.gen(function* () {
46− const { executor, engine } = yield* makeExecutionStack(
47− userId,
48− organizationId,
49− organizationName,
50− );
51−
52− const requestServices = Layer.mergeAll(
53− Layer.succeed(ExecutorService)(executor),
54− Layer.succeed(ExecutionEngineService)(engine),
55− Layer.succeed(OpenApiExtensionService)(executor.openapi),
56− Layer.succeed(McpExtensionService)(executor.mcp),
57− Layer.succeed(GraphqlExtensionService)(executor.graphql),
58− );
59−
60− const protectedApiLayer = ProtectedCloudApiLive.pipe(
61− Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),
62− Layer.provideMerge(RouterConfig),
63− );
64−
65− return yield* HttpRouter.toHttpEffect(protectedApiLayer).pipe(
66− Effect.flatMap(HttpMiddleware.logger),
67− Effect.provide(requestServices),
68− );
69− });
70−
71−export const ProtectedApiApp = Effect.gen(function* () {
72− const request = yield* HttpServerRequest.HttpServerRequest;
73− const session = yield* lookupOrgForRequest(request);
74− if (!session) {
75− return yield* Effect.fail(
76− new HttpResponseError({
77− status: 403,
78− code: "no_organization",
79− message: "No organization in session",
80− }),
81− );
82− }
83−
84− return yield* createProtectedApp(session.userId, session.org.id, session.org.name);
85−}).pipe(
86− Effect.provide(SharedServices),
87− Effect.catchCause((err) => {
88− if (isServerError(err)) {
89− console.error("[api] request failed:", err instanceof Error ? err.stack : err);
90− }
91− return Effect.succeed(toErrorServerResponse(err));
66+ const context = yield* Effect.context<
67+ WorkOSAuth | UserStoreService | AutumnService | DbService
68+ >();
69+ return (httpEffect) =>
70+ Effect.gen(function* () {
71+ const request = yield* HttpServerRequest.HttpServerRequest;
72+ const webRequest = yield* HttpServerRequest.toWeb(request);
73+ const workos = yield* WorkOSAuth;
74+ const session = yield* workos.authenticateRequest(webRequest);
75+ if (!session || !session.organizationId) {
76+ return yield* new HttpResponseError({
77+ status: 403,
78+ code: "no_organization",
79+ message: "No organization in session",
80+ });
81+ }
82+ const org = yield* authorizeOrganization(session.userId, session.organizationId);
83+ if (!org) {
84+ return yield* new HttpResponseError({
85+ status: 403,
86+ code: "no_organization",
87+ message: "No organization in session",
88+ });
89+ }
90+ const auth = AuthContext.of({
91+ accountId: session.userId,
92+ organizationId: org.id,
93+ email: session.email,
94+ name: `${session.firstName ?? ""} ${session.lastName ?? ""}`.trim() || null,
95+ avatarUrl: session.avatarUrl ?? null,
96+ });
97+ const { executor, engine } = yield* makeExecutionStack(auth.accountId, org.id, org.name);
98+ return yield* httpEffect.pipe(
99+ Effect.provideService(AuthContext, auth),
100+ Effect.provideService(ExecutorService, executor),
101+ Effect.provideService(ExecutionEngineService, engine),
102+ Effect.provideService(OpenApiExtensionService, executor.openapi),
103+ Effect.provideService(McpExtensionService, executor.mcp),
104+ Effect.provideService(GraphqlExtensionService, executor.graphql),
105+ );
106+ }).pipe(Effect.provideContext(context));
92107 }),
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),
93114 );
apps/cloud/src/api/router.ts+24−56
@@ -1,56 +1,24 @@
1−import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
2−import { Context, Effect, Scope } from "effect";
3−
4−export type ApiRouteApp = Effect.Effect<
5− HttpServerResponse.HttpServerResponse,
6− unknown,
7− HttpServerRequest.HttpServerRequest | Scope.Scope
8−>;
9−
10−type RequestAppService = {
11− readonly app: ApiRouteApp;
12−};
13−
14−export class OrgRequestHandlerService extends Context.Service<OrgRequestHandlerService, RequestAppService>()(
15− "@executor-js/cloud/OrgRequestHandlerService",
16−) {}
17−
18−export class NonProtectedRequestHandlerService extends Context.Service<NonProtectedRequestHandlerService, RequestAppService>()(
19− "@executor-js/cloud/NonProtectedRequestHandlerService",
20−) {}
21−
22−export class AutumnRequestHandlerService extends Context.Service<AutumnRequestHandlerService, RequestAppService>()(
23− "@executor-js/cloud/AutumnRequestHandlerService",
24−) {}
25−
26−export class ProtectedRequestHandlerService extends Context.Service<ProtectedRequestHandlerService, RequestAppService>()(
27− "@executor-js/cloud/ProtectedRequestHandlerService",
28−) {}
29−
30−export const ApiRouterApp = Effect.gen(function* () {
31− const org = yield* OrgRequestHandlerService;
32− const nonProtected = yield* NonProtectedRequestHandlerService;
33− const autumn = yield* AutumnRequestHandlerService;
34− const protectedHandler = yield* ProtectedRequestHandlerService;
35− const asRouteApp = (app: ApiRouteApp) => {
36− const webHandler = HttpEffect.toWebHandler(app);
37− return HttpEffect.fromWebHandler((request) => webHandler(request)).pipe(
38− Effect.catchCause(() =>
39− Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
40− ),
41− );
42− };
43−
44− const router = yield* HttpRouter.make;
45− yield* router.add("*", "/org/*", asRouteApp(org.app));
46− yield* router.add("*", "/auth/*", asRouteApp(nonProtected.app));
47− yield* router.add("*", "/autumn/*", asRouteApp(autumn.app));
48− yield* router.add("*", "/scope", asRouteApp(protectedHandler.app));
49− yield* router.add("*", "/scopes/*", asRouteApp(protectedHandler.app));
50− yield* router.add("*", "/executions/*", asRouteApp(protectedHandler.app));
51− yield* router.add("*", "/oauth/*", asRouteApp(protectedHandler.app));
52−
53− return router.asHttpEffect();
54−});
55−
56−export const ApiRequestHandler = ApiRouterApp;
1+import { Layer } from "effect";
2+
3+import { AutumnRoutesLive } from "./autumn";
4+import {
5+ NonProtectedApiLive,
6+ OrgApiLive,
7+ RouterConfig,
8+ SharedServices,
9+} from "./layers";
10+import { ProtectedApiLive } from "./protected";
11+
12+// One router. Each sub-API contributes its routes via `HttpApiBuilder.layer`,
13+// which calls `HttpRouter.use(...)` under the hood. Autumn's catch-all proxy
14+// is added as a plain `HttpRouter.add` route. They all merge into the same
15+// routing table; there is no outer-then-inner router stacking.
16+export const ApiLive = Layer.mergeAll(
17+ NonProtectedApiLive,
18+ OrgApiLive,
19+ ProtectedApiLive,
20+ AutumnRoutesLive,
21+).pipe(
22+ Layer.provideMerge(RouterConfig),
23+ Layer.provideMerge(SharedServices),
24+);
apps/cloud/src/services/__test-harness__/api-harness.ts+50−62
@@ -16,7 +16,12 @@ · 15 unmodified lines
1616
1717 import { Effect, Layer } from "effect";
1818 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";
2025
2126 import {
2227 ExecutionEngineService,
@@ -48,7 +53,7 @@ import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; · 25 unmodified lines
4853 import { McpExtensionService } from "@executor-js/plugin-mcp/api";
4954 import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api";
5055
51−import { AuthContext, OrgAuth } from "../../auth/middleware";
56+import { AuthContext } from "../../auth/middleware";
5257 import {
5358 ProtectedCloudApi,
5459 ProtectedCloudApiHandlers,
@@ -209,10 +214,27 @@ const createTestScopedExecutor = ( · 154 unmodified lines
209214 // HTTP plumbing
210215 // ---------------------------------------------------------------------------
211216
212−const FakeOrgAuthLive = Layer.succeed(
213− OrgAuth,
214− {
215− cookie: (httpApp) =>
217+// Test version of the production `ExecutionStackMiddleware` — reads the
218+// `x-test-org-id` (and optional `x-test-user-id`) header, builds a
219+// test-scoped executor against the live postgres test db with a fake
220+// WorkOS vault, and provides `AuthContext` + the executor services to the
221+// handler. Mirrors prod's HttpRouter middleware but with test-mode
222+// constructors.
223+const TestExecutionStackMiddleware = HttpRouter.middleware<{
224+ provides:
225+ | AuthContext
226+ | ExecutorService
227+ | ExecutionEngineService
228+ | OpenApiExtensionService
229+ | McpExtensionService
230+ | GraphqlExtensionService;
231+}>()(
232+ // Layer-time setup — captures `DbService` so the per-request function
233+ // only depends on `HttpRouter`-Provided context. See `api/protected.ts`
234+ // for the same pattern.
235+ Effect.gen(function* () {
236+ const context = yield* Effect.context<DbService>();
237+ return (httpEffect) =>
216238 Effect.gen(function* () {
217239 const request = yield* HttpServerRequest.HttpServerRequest;
218240 const orgId = request.headers[TEST_ORG_HEADER];
@@ -224,7 +246,13 @@ const FakeOrgAuthLive = Layer.succeed( · 5 unmodified lines
224246 typeof userHeader === "string" && userHeader.length > 0
225247 ? userHeader
226248 : 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(
228256 Effect.provideService(
229257 AuthContext,
230258 AuthContext.of({
@@ -235,66 +263,26 @@ const FakeOrgAuthLive = Layer.succeed( · 4 unmodified lines
235263 avatarUrl: null,
236264 }),
237265 ),
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),
238271 );
239− }),
240− },
241−);
272+ }).pipe(Effect.provideContext(context));
273+ }),
274+).layer;
242275
243276 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),
245283 );
246284
247−const buildWebHandlerForScope = (userId: string, orgId: string, orgName: string) =>
248− Effect.gen(function* () {
249− const executor = yield* createTestScopedExecutor(userId, orgId, orgName);
250− const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
251− const services = Layer.mergeAll(
252− Layer.succeed(ExecutorService)(executor),
253− Layer.succeed(ExecutionEngineService)(engine),
254− Layer.succeed(OpenApiExtensionService)(executor.openapi),
255− Layer.succeed(McpExtensionService)(executor.mcp),
256− Layer.succeed(GraphqlExtensionService)(executor.graphql),
257− );
258− return HttpRouter.toWebHandler(
259− TestApiLive.pipe(
260− Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),
261− Layer.provideMerge(services),
262− Layer.provideMerge(RouterConfig),
263− Layer.provideMerge(HttpServer.layerServices),
264− ),
265− { disableLogger: true },
266− );
267− });
268−
269−const RouterApp = Effect.gen(function* () {
270− const request = yield* HttpServerRequest.HttpServerRequest;
271− const webRequest = yield* HttpServerRequest.toWeb(request);
272− const orgId = request.headers[TEST_ORG_HEADER];
273− if (!orgId || typeof orgId !== "string") {
274− return yield* Effect.die(new Error("missing x-test-org-id"));
275− }
276− const userHeader = request.headers[TEST_USER_HEADER];
277− const userId =
278− typeof userHeader === "string" && userHeader.length > 0
279− ? userHeader
280− : defaultUserFor(orgId);
281− const web = yield* buildWebHandlerForScope(userId, orgId, `Org ${orgId}`);
282− const response = yield* Effect.promise(async () => {
283− try {
284− return await web.handler(webRequest);
285− } finally {
286− await web.dispose();
287− }
288− });
289− return HttpServerResponse.raw(response, { status: response.status, headers: response.headers });
290−});
291−
292−const handler = HttpEffect.toWebHandler(
293− RouterApp.pipe(
294− Effect.provide(DbService.Live),
295− Effect.provide(HttpServer.layerServices),
296− ),
297−);
285+const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler;
298286
299287 export const fetchForOrg = (orgId: string): typeof globalThis.fetch =>
300288 ((input: RequestInfo | URL, init?: RequestInit) => {