From 42ad834cf6597838e16a43242529466cb5a2d6cf Mon Sep 17 00:00:00 2001 From: Yu-Hong Shen Date: Thu, 26 Mar 2026 12:02:15 +0000 Subject: [PATCH 1/2] fix(platform): preserve fiber context in HttpLayerRouter.addHttpApi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `HttpLayerRouter.addHttpApi` wrapped route handlers using `Effect.provide(route.handler, context)`, it replaced the entire fiber context (via `fiberRefLocally`). This silently discarded any services injected at runtime — most notably `Session` provided by API-level `HttpApiMiddleware` via `provideServiceEffect`. The bug manifested as: - Endpoints returning 200 instead of 401 when rejecting middleware was configured (middleware was skipped entirely for the second API in `Layer.mergeAll`) - Endpoints returning 500 "Service not found: Session" when accepting middleware was configured (Session injected by middleware was overwritten before the handler ran) Fix: replace `Effect.provide` with `Effect.mapInputContext` so the build-time platform services context is *merged* into the runtime fiber context rather than replacing it. This preserves all request-time services (including Session from API-level middleware). Adds regression tests for GitHub issue #6121. Fixes #6121 Co-Authored-By: Claude Sonnet 4.6 --- .../test/HttpApiLayerRouterMiddleware.test.ts | 190 ++++++++++++++++++ packages/platform/src/HttpLayerRouter.ts | 2 +- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 packages/platform-node/test/HttpApiLayerRouterMiddleware.test.ts diff --git a/packages/platform-node/test/HttpApiLayerRouterMiddleware.test.ts b/packages/platform-node/test/HttpApiLayerRouterMiddleware.test.ts new file mode 100644 index 00000000000..b8064763a8a --- /dev/null +++ b/packages/platform-node/test/HttpApiLayerRouterMiddleware.test.ts @@ -0,0 +1,190 @@ +/** + * Regression tests for GitHub issue #6121: + * "@effect/platform HttpApi: middleware is skipped" + * https://github.com/Effect-TS/effect/issues/6121 + * + * Verifies that API-level middleware defined with `.middleware()` is correctly + * applied when using `HttpLayerRouter.addHttpApi` with multiple APIs combined + * via `Layer.mergeAll`. + */ +import { + HttpApi, + HttpApiBuilder, + HttpApiClient, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, + HttpApiMiddleware, + HttpApiSecurity, + HttpLayerRouter +} from "@effect/platform" +import { NodeHttpServer } from "@effect/platform-node" +import { assert, describe, it } from "@effect/vitest" +import { Context, Effect, Layer, Schema } from "effect" + +// --- Domain Types --- + +class Session extends Context.Tag("test/LRMw/Session")< + Session, + { readonly id: string } +>() {} + +// Security middleware with `provides: Session` — applied at the API level +class InternalAuthorization extends HttpApiMiddleware.Tag()( + "test/LRMw/InternalAuthorization", + { + failure: HttpApiError.Unauthorized, + provides: Session, + security: { apiKey: HttpApiSecurity.bearer } + } +) {} + +// --- APIs --- + +// External API — no authentication required +const externalApi = HttpApi.make("external-api").add( + HttpApiGroup.make("Posts") + .add(HttpApiEndpoint.get("posts", "/").addSuccess(Schema.String)) + .prefix("/posts") +) + +// Internal API — API-level middleware applied to all endpoints +// Two groups are included to match the issue reproduction exactly +const internalApi = HttpApi.make("internal-api") + .add( + HttpApiGroup.make("Customers") + .add(HttpApiEndpoint.get("customers", "/").addSuccess(Schema.Array(Schema.String))) + .prefix("/customers") + ) + .add( + HttpApiGroup.make("Users") + .add(HttpApiEndpoint.get("users", "/").addSuccess(Schema.Array(Schema.String))) + .prefix("/users") + ) + .middleware(InternalAuthorization) + +// --- Handlers --- + +const PostsLive = HttpApiBuilder.group(externalApi, "Posts", (handlers) => + handlers.handle("posts", () => Effect.succeed("ok")) +) + +// Users handler: does NOT use Session (but should still be guarded by middleware) +const UsersLive = HttpApiBuilder.group(internalApi, "Users", (handlers) => + handlers.handle("users", () => Effect.succeed(["user1", "user2"])) +) + +// Customers handler: uses Session (provided by the middleware upon auth success) +const CustomersLive = HttpApiBuilder.group(internalApi, "Customers", (handlers) => + handlers.handle( + "customers", + () => + Effect.gen(function*() { + const session = yield* Session + return ["customer1", "customer2", session.id] + }) + ) +) + +// --- Middleware implementations --- + +// Rejects all requests — used to verify middleware enforcement +const RejectAllAuth = Layer.succeed(InternalAuthorization, { + apiKey: (_token) => Effect.fail(new HttpApiError.Unauthorized()) +}) + +// Accepts all requests and provides a mock session +const AcceptAllAuth = Layer.succeed(InternalAuthorization, { + apiKey: (_token) => Effect.succeed({ id: "test-session-123" }) +}) + +// --- Test server setup (mirrors the issue reproduction pattern) --- + +const ExternalRoutes = HttpLayerRouter.addHttpApi(externalApi).pipe( + Layer.provide(PostsLive) +) + +const makeInternalRoutes = (auth: Layer.Layer) => + HttpLayerRouter.addHttpApi(internalApi).pipe( + Layer.provide([UsersLive, CustomersLive]), + Layer.provide(auth) + ) + +const makeTestServer = (auth: Layer.Layer) => + HttpLayerRouter.serve( + Layer.mergeAll(ExternalRoutes, makeInternalRoutes(auth)), + { disableLogger: true } + ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) + +// --- Tests --- + +describe("HttpLayerRouter - API-level middleware (issue #6121)", () => { + describe("with rejecting middleware", () => { + const TestServer = makeTestServer(RejectAllAuth) + + it.effect( + "users endpoint is protected — returns 401 not 200", + () => + Effect.gen(function*() { + // Bug: without the fix this returned 200 (middleware was silently skipped) + const client = yield* HttpApiClient.make(internalApi) + const result = yield* client.Users.users().pipe(Effect.flip) + assert.instanceOf( + result, + HttpApiError.Unauthorized, + "Expected 401 Unauthorized from middleware, got something else (middleware may be skipped)" + ) + }).pipe(Effect.provide(TestServer)) + ) + + it.effect( + "customers endpoint is protected — returns 401 not 500", + () => + Effect.gen(function*() { + // Bug: without the fix this returned 500 "Service not found: Session" + // because the middleware never ran to inject the Session service + const client = yield* HttpApiClient.make(internalApi) + const result = yield* client.Customers.customers().pipe(Effect.flip) + assert.instanceOf( + result, + HttpApiError.Unauthorized, + "Expected 401 Unauthorized from middleware, got something else (Session may have leaked)" + ) + }).pipe(Effect.provide(TestServer)) + ) + + it.effect( + "external API routes remain accessible without authentication", + () => + Effect.gen(function*() { + const client = yield* HttpApiClient.make(externalApi) + const result = yield* client.Posts.posts() + assert.strictEqual(result, "ok") + }).pipe(Effect.provide(TestServer)) + ) + }) + + describe("with accepting middleware", () => { + const TestServer = makeTestServer(AcceptAllAuth) + + it.effect( + "users endpoint is accessible with valid auth — middleware provides Session", + () => + Effect.gen(function*() { + const client = yield* HttpApiClient.make(internalApi) + const users = yield* client.Users.users() + assert.deepStrictEqual(users, ["user1", "user2"]) + }).pipe(Effect.provide(TestServer)) + ) + + it.effect( + "customers endpoint receives Session from middleware and returns data", + () => + Effect.gen(function*() { + const client = yield* HttpApiClient.make(internalApi) + const customers = yield* client.Customers.customers() + assert.deepStrictEqual(customers, ["customer1", "customer2", "test-session-123"]) + }).pipe(Effect.provide(TestServer)) + ) + }) +}) diff --git a/packages/platform/src/HttpLayerRouter.ts b/packages/platform/src/HttpLayerRouter.ts index fba772bc013..64032471e76 100644 --- a/packages/platform/src/HttpLayerRouter.ts +++ b/packages/platform/src/HttpLayerRouter.ts @@ -1032,7 +1032,7 @@ export const addHttpApi = Context.merge(context, input)) })) } From 5fd07e0c8882e06c486c6e0281feb8f1503d7924 Mon Sep 17 00:00:00 2001 From: Yu-Hong Shen Date: Thu, 26 Mar 2026 12:24:31 +0000 Subject: [PATCH 2/2] chore: add changeset for HttpLayerRouter API middleware fix Co-Authored-By: Claude Sonnet 4.6 --- ...-httplayerrouter-api-middleware-skipped.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/fix-httplayerrouter-api-middleware-skipped.md diff --git a/.changeset/fix-httplayerrouter-api-middleware-skipped.md b/.changeset/fix-httplayerrouter-api-middleware-skipped.md new file mode 100644 index 00000000000..f6302c6ed06 --- /dev/null +++ b/.changeset/fix-httplayerrouter-api-middleware-skipped.md @@ -0,0 +1,29 @@ +--- +"@effect/platform": patch +--- + +Fix `HttpLayerRouter.addHttpApi` silently skipping API-level middleware. + +When using `HttpLayerRouter.addHttpApi` with `HttpApiMiddleware` (e.g. bearer auth +that provides a `Session` service), the middleware was either skipped entirely or +its injected services (like `Session`) were unavailable inside route handlers. + +**Root cause**: Route handlers were wrapped with `Effect.provide(handler, context)` +which calls `fiberRefLocally` and **replaces** the entire fiber context. Any services +injected at request time by API-level middleware via `provideServiceEffect` were +overwritten before the handler ran. + +**Fix**: Replace `Effect.provide` with `Effect.mapInputContext` to **merge** the +captured build-time platform services into the runtime fiber context: + +```ts +// Before (broken): replaces fiber context, loses Session +handler: Effect.provide(route.handler, context) + +// After (fixed): merges build-time context into runtime context +handler: Effect.mapInputContext(route.handler, (input) => Context.merge(context, input)) +``` + +This is the same pattern `HttpApiBuilder.group` already uses internally. + +Fixes #6121.