| 1 | --- |
| 2 | title: HTTP API middleware |
| 3 | description: Authenticate each request and provide a typed current user to Effect HTTP API handlers. |
| 4 | --- |
| 5 | |
| 6 | ## Define the service, middleware, and protected API |
| 7 | |
| 8 | ```typescript |
| 9 | // worker.ts |
| 10 | import { BetterAuth } from "@alchemy.run/better-auth"; |
| 11 | import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 12 | import { RuntimeContext } from "alchemy"; |
| 13 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 14 | import * as Http from "alchemy/Http"; |
| 15 | import * as Context from "effect/Context"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Schema from "effect/Schema"; |
| 19 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 20 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 21 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 22 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 23 | import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; |
| 24 | import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; |
| 25 | import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware"; |
| 26 | |
| 27 | const makeAuth = BetterAuth({ |
| 28 | basePath: "/api/auth", |
| 29 | emailAndPassword: { enabled: true }, |
| 30 | }); |
| 31 | |
| 32 | class Auth extends Context.Service< |
| 33 | Auth, |
| 34 | Effect.Success<typeof makeAuth> |
| 35 | >()("app/Auth") { |
| 36 | static readonly layer = Layer.effect(Auth, makeAuth); |
| 37 | } |
| 38 | |
| 39 | const User = Schema.Struct({ |
| 40 | id: Schema.String, |
| 41 | name: Schema.String, |
| 42 | email: Schema.String, |
| 43 | }); |
| 44 | |
| 45 | class CurrentUser extends Context.Service< |
| 46 | CurrentUser, |
| 47 | typeof User.Type |
| 48 | >()("app/CurrentUser") {} |
| 49 | |
| 50 | class Unauthorized extends Schema.TaggedError<Unauthorized>()( |
| 51 | "Unauthorized", {}, { httpApiStatus: 401 }, |
| 52 | ) {} |
| 53 | |
| 54 | class AuthenticationUnavailable extends Schema.TaggedError<AuthenticationUnavailable>()( |
| 55 | "AuthenticationUnavailable", {}, { httpApiStatus: 503 }, |
| 56 | ) {} |
| 57 | |
| 58 | class Authentication extends HttpApiMiddleware.Service< |
| 59 | Authentication, |
| 60 | { provides: CurrentUser } |
| 61 | >()("app/Authentication", { |
| 62 | error: [Unauthorized, AuthenticationUnavailable], |
| 63 | }) { |
| 64 | static readonly layer = Layer.effect( |
| 65 | Authentication, |
| 66 | Effect.gen(function* () { |
| 67 | const auth = yield* Auth; |
| 68 | return (httpEffect) => Effect.gen(function* () { |
| 69 | const session = yield* auth.getSession().pipe( |
| 70 | Effect.mapError(() => new AuthenticationUnavailable()), |
| 71 | Effect.catchDefect(() => Effect.fail(new AuthenticationUnavailable())), |
| 72 | ); |
| 73 | if (session === null) return yield* Effect.fail(new Unauthorized()); |
| 74 | return yield* Effect.provideService(httpEffect, CurrentUser, { |
| 75 | id: session.user.id, |
| 76 | name: session.user.name, |
| 77 | email: session.user.email, |
| 78 | }); |
| 79 | }).pipe(Effect.provide(RuntimeContext.phantom)); |
| 80 | }), |
| 81 | ); |
| 82 | } |
| 83 | |
| 84 | class PrivateApi extends HttpApiGroup.make("private") |
| 85 | .add(HttpApiEndpoint.get("me", "/api/me", { success: User })) |
| 86 | .middleware(Authentication) {} |
| 87 | |
| 88 | class PublicApi extends HttpApiGroup.make("public").add( |
| 89 | HttpApiEndpoint.get("health", "/api/health", { |
| 90 | success: Schema.Struct({ ok: Schema.Boolean }), |
| 91 | }), |
| 92 | ) {} |
| 93 | |
| 94 | class AppApi extends HttpApi.make("app").add(PublicApi).add(PrivateApi) {} |
| 95 | |
| 96 | const PrivateLive = HttpApiBuilder.group(AppApi, "private", (handlers) => |
| 97 | handlers.handle("me", () => CurrentUser), |
| 98 | ); |
| 99 | const PublicLive = HttpApiBuilder.group(AppApi, "public", (handlers) => |
| 100 | handlers.handle("health", () => Effect.succeed({ ok: true })), |
| 101 | ); |
| 102 | const HttpLive = HttpApiBuilder.layer(AppApi).pipe( |
| 103 | Layer.provide(Layer.merge(PublicLive, PrivateLive)), |
| 104 | Layer.provide(Authentication.layer), |
| 105 | Layer.provide(Http.Platform), |
| 106 | ); |
| 107 | |
| 108 | const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 109 | |
| 110 | export default class AuthApi extends Cloudflare.Worker<AuthApi>()( |
| 111 | "AuthApi", |
| 112 | { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } }, |
| 113 | Effect.gen(function* () { |
| 114 | const auth = yield* Auth; |
| 115 | const fetch = yield* HttpRouter.toHttpEffect(HttpLive); |
| 116 | return { |
| 117 | fetch: Effect.gen(function* () { |
| 118 | const request = yield* HttpServerRequest; |
| 119 | const path = request.url.split("?")[0]; |
| 120 | if (path === "/api/auth" || path.startsWith("/api/auth/")) { |
| 121 | return yield* auth.fetch; |
| 122 | } |
| 123 | return yield* fetch; |
| 124 | }), |
| 125 | }; |
| 126 | }).pipe( |
| 127 | Effect.provide(Auth.layer.pipe(Layer.provide(CloudflareD1(AuthDb)))), |
| 128 | ), |
| 129 | ) {} |
| 130 | ``` |
| 131 | |
| 132 | The layer captures `Auth` once, but reads the session inside each returned request effect. It provides only that request's user to the handler; never construct a process-wide user layer or accept identity from a request body. |
| 133 | |
| 134 | `auth.getSession()` reads headers from the ambient Effect HTTP request. Outside that boundary, `auth.getSession(headers)` accepts a web `Headers` object. |
| 135 | |
| 136 | Only `null` becomes `Unauthorized` with HTTP `401`; typed lookup failures and unexpected lookup defects become `AuthenticationUnavailable` with HTTP `503`. The catch surrounds session resolution, not the downstream handler, so it cannot hide unrelated application failures. |
| 137 | |
| 138 | For diagnostics, `BetterAuthApiError.statusCode` is numeric and `body.code` identifies the upstream error. Log those fields rather than the complete error, whose headers and cause can contain credentials. |
| 139 | |
| 140 | `RuntimeContext.phantom` adapts Alchemy's runtime marker to the native HTTP middleware boundary. It does not authenticate a request or provide a session. |
| 141 | |
| 142 | The private group requires authentication, while the health group and Better Auth routes remain public. Dispatching to `auth.fetch` first preserves status, redirects, and `Set-Cookie` headers, including cookies written during session refresh. |
| 143 | |
| 144 | ## Deploy the standalone example |
| 145 | |
| 146 | ```typescript |
| 147 | // alchemy.run.ts |
| 148 | import * as Alchemy from "alchemy"; |
| 149 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 150 | import * as Effect from "effect/Effect"; |
| 151 | import AuthApi from "./worker.ts"; |
| 152 | |
| 153 | export default Alchemy.Stack( |
| 154 | "AuthMiddleware", |
| 155 | { providers: Cloudflare.providers(), state: Alchemy.localState() }, |
| 156 | Effect.gen(function* () { |
| 157 | const api = yield* AuthApi; |
| 158 | return { url: api.url }; |
| 159 | }), |
| 160 | ); |
| 161 | ``` |
| 162 | |
| 163 | Use Bun, matching `alchemy` and `@alchemy.run/better-auth` releases, Better Auth 1.7.5, Effect 4, and `kysely`, with Cloudflare credentials configured. This stack owns its D1 database, migrations, and stable signing secret; preserve its local state. |
| 164 | |
| 165 | ```sh |
| 166 | bunx alchemy dev |
| 167 | ``` |
| 168 | |
| 169 | Alchemy selects an available port and prints the origin. In another shell, enter that origin without a trailing slash after `read`: |
| 170 | |
| 171 | ```sh |
| 172 | read -r BASE_URL |
| 173 | curl -i "$BASE_URL/api/me" |
| 174 | curl -i "$BASE_URL/api/health" |
| 175 | ``` |
| 176 | |
| 177 | The first request returns `401` with `{"_tag":"Unauthorized"}`; the second returns `200` with `{"ok":true}`. An authenticated `/api/me` response contains only `id`, `name`, and `email`, not a session token. |
| 178 | |
| 179 | ## Keep optional authentication explicit |
| 180 | |
| 181 | An intentionally public endpoint can call `auth.getSession()` within its request effect and treat only `null` as anonymous. Keep lookup failures in the error channel or map them to a declared server error; do not attach required-auth middleware merely to obtain optional identity. |
| 182 | |
| 183 | Authentication identifies the caller; authorization still needs server-side ownership or permission checks. For session lifetime, revocation, and cookie-cache behavior, see [Better Auth's session documentation](https://better-auth.com/docs/concepts/session-management). |