| 1 | import type { RuntimeContext } from "alchemy"; |
| 2 | import { Random } from "alchemy"; |
| 3 | import type { HttpEffect } from "alchemy/Http"; |
| 4 | import { makeExecutionMemo } from "alchemy/Runtime/ExecutionMemo"; |
| 5 | import { betterAuth, type Auth, type BetterAuthOptions } from "better-auth"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Redacted from "effect/Redacted"; |
| 9 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 10 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 11 | import { makeApiProxy, type BetterAuthApi } from "./ApiProxy.ts"; |
| 12 | import { Database } from "./Database.ts"; |
| 13 | import type { BetterAuthApiError } from "./Errors.ts"; |
| 14 | import { SecondaryStorage, toPromiseStorage } from "./SecondaryStorage.ts"; |
| 15 | |
| 16 | /** |
| 17 | * Better Auth options, minus the fields alchemy owns: |
| 18 | * |
| 19 | * - `database` comes from the {@link Database} platform layer |
| 20 | * (`Cloudflare.D1`, `Postgres`, `Memory`, ...) |
| 21 | * - `secondaryStorage` comes from the optional {@link SecondaryStorage} |
| 22 | * layer (`Cloudflare.KV`) |
| 23 | * - `secret` is widened to accept `Redacted` values and Effects (resource |
| 24 | * outputs), and defaults to an auto-provisioned stable random secret |
| 25 | * |
| 26 | * plus alchemy extensions (`id`, `migrate`). |
| 27 | */ |
| 28 | export interface BetterAuthProps extends Omit< |
| 29 | BetterAuthOptions, |
| 30 | "database" | "secondaryStorage" | "secret" |
| 31 | > { |
| 32 | /** |
| 33 | * Distinguishes multiple BetterAuth instances in one namespace — suffixes |
| 34 | * the auto-provisioned secret resource and the migration action logical |
| 35 | * ids. |
| 36 | * |
| 37 | * @default "BetterAuth" |
| 38 | */ |
| 39 | readonly id?: string; |
| 40 | /** |
| 41 | * Deploy-time automatic schema migration. Runs as an internal alchemy |
| 42 | * Action during `alchemy deploy` (never at plan, never inside the |
| 43 | * deployed runtime) and re-runs only when the auth schema (plugins, |
| 44 | * additional fields, indexes) or the target database changes. |
| 45 | * |
| 46 | * `false` opts out. `true` on a Database layer without migration support |
| 47 | * (Memory, Drizzle) fails the deploy with a descriptive error. |
| 48 | * |
| 49 | * @default true when the Database layer supports migration |
| 50 | */ |
| 51 | readonly migrate?: boolean; |
| 52 | /** |
| 53 | * The Better Auth signing secret. Accepts a literal string, a `Redacted` |
| 54 | * value, or an Effect resolving to one (e.g. another resource's output |
| 55 | * accessor). |
| 56 | * |
| 57 | * @default an auto-provisioned `Alchemy.Random` secret (`${id}Secret`), |
| 58 | * generated once and stable across deploys |
| 59 | */ |
| 60 | readonly secret?: |
| 61 | | string |
| 62 | | Redacted.Redacted<string> |
| 63 | | Effect.Effect<Redacted.Redacted<string>, never, RuntimeContext>; |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * The Better Auth options type seen by `Auth<Options>` — the user's literal |
| 68 | * options with the alchemy extension fields stripped, so plugin/session/user |
| 69 | * type inference flows through untouched. |
| 70 | */ |
| 71 | export type AuthOptions<O extends BetterAuthProps> = |
| 72 | Omit<O, "id" | "migrate" | "secret"> extends infer T extends BetterAuthOptions |
| 73 | ? T |
| 74 | : BetterAuthOptions; |
| 75 | |
| 76 | /** The inferred `{ session, user }` shape for a given options type. */ |
| 77 | export type Session<O extends BetterAuthProps> = Auth< |
| 78 | AuthOptions<O> |
| 79 | >["$Infer"]["Session"]; |
| 80 | |
| 81 | export interface BetterAuthInstance<O extends BetterAuthProps> { |
| 82 | /** |
| 83 | * The per-execution Better Auth instance (memoized on the execution |
| 84 | * scope). Escape hatch for anything the effectified surface doesn't |
| 85 | * cover (`$context`, `asResponse`/`returnHeaders` call forms, ...). |
| 86 | */ |
| 87 | readonly auth: Effect.Effect<Auth<AuthOptions<O>>, never, RuntimeContext>; |
| 88 | /** |
| 89 | * Every `auth.api.*` endpoint mirrored as an Effect with a typed |
| 90 | * {@link BetterAuthApiError} failure. |
| 91 | */ |
| 92 | readonly api: BetterAuthApi<Auth<AuthOptions<O>>["api"]>; |
| 93 | /** |
| 94 | * HTTP handler for the Better Auth routes — mount it under your |
| 95 | * `basePath` (default `/api/auth`): |
| 96 | * |
| 97 | * ```typescript |
| 98 | * fetch: Effect.gen(function* () { |
| 99 | * const request = yield* HttpServerRequest; |
| 100 | * if (request.url.startsWith("/api/auth")) { |
| 101 | * return yield* auth.fetch; |
| 102 | * } |
| 103 | * // ... |
| 104 | * }) |
| 105 | * ``` |
| 106 | */ |
| 107 | readonly fetch: HttpEffect<RuntimeContext>; |
| 108 | /** |
| 109 | * Look up the session for the ambient request (or explicit `Headers`). |
| 110 | * Resolves `null` for anonymous requests — failures are real errors, not |
| 111 | * missing sessions. |
| 112 | */ |
| 113 | readonly getSession: { |
| 114 | (): Effect.Effect< |
| 115 | Session<O> | null, |
| 116 | BetterAuthApiError, |
| 117 | RuntimeContext | HttpServerRequest.HttpServerRequest |
| 118 | >; |
| 119 | ( |
| 120 | headers: Headers, |
| 121 | ): Effect.Effect<Session<O> | null, BetterAuthApiError, RuntimeContext>; |
| 122 | }; |
| 123 | /** Type-level mirror of `auth.$Infer` (phantom — no runtime value). */ |
| 124 | readonly Infer: Auth<AuthOptions<O>>["$Infer"]; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Create a Better Auth instance wired to alchemy. |
| 129 | * |
| 130 | * The database comes from a {@link Database} platform layer provided on |
| 131 | * the surrounding Worker/Function impl effect; the signing secret defaults |
| 132 | * to a stable auto-provisioned random secret; schema migrations run |
| 133 | * automatically at deploy time. |
| 134 | * |
| 135 | * ```typescript |
| 136 | * Effect.gen(function* () { |
| 137 | * const auth = yield* BetterAuth({ |
| 138 | * basePath: "/api/auth", |
| 139 | * emailAndPassword: { enabled: true }, |
| 140 | * }); |
| 141 | * return { fetch: ... }; |
| 142 | * }).pipe(Effect.provide(CloudflareD1(Db))) |
| 143 | * ``` |
| 144 | */ |
| 145 | export const BetterAuth = <const O extends BetterAuthProps>( |
| 146 | options: O, |
| 147 | ): Effect.Effect<BetterAuthInstance<O>, never, Database> => |
| 148 | Effect.gen(function* () { |
| 149 | const { |
| 150 | id = "BetterAuth", |
| 151 | migrate, |
| 152 | secret, |
| 153 | ...userOptions |
| 154 | } = options as BetterAuthProps; |
| 155 | const authOptions = userOptions as BetterAuthOptions; |
| 156 | |
| 157 | const db = yield* Database; |
| 158 | const secondary = yield* Effect.serviceOption(SecondaryStorage); |
| 159 | |
| 160 | // Resolve the signing secret to an Effect usable at runtime. The |
| 161 | // default is a Random resource whose Output accessor binds the value |
| 162 | // into the host environment at deploy and reads it back at runtime. |
| 163 | let secretAccessor: Effect.Effect<Redacted.Redacted<string>>; |
| 164 | if (secret === undefined) { |
| 165 | const resource = yield* Random(`${id}Secret`); |
| 166 | secretAccessor = yield* resource.text; |
| 167 | } else if (typeof secret === "string") { |
| 168 | secretAccessor = Effect.succeed(Redacted.make(secret)); |
| 169 | } else if (Effect.isEffect(secret)) { |
| 170 | secretAccessor = secret as Effect.Effect<Redacted.Redacted<string>>; |
| 171 | } else { |
| 172 | secretAccessor = Effect.succeed(secret); |
| 173 | } |
| 174 | |
| 175 | // Deploy-time migration registration. The whole block — including the |
| 176 | // Migrate module with its better-auth/db + kysely imports — is |
| 177 | // unreachable at runtime and dead-code-eliminated from bundles. |
| 178 | if (!globalThis.__ALCHEMY_RUNTIME__) { |
| 179 | const { registerMigration } = yield* Effect.promise( |
| 180 | () => import("./Migrate.ts"), |
| 181 | ); |
| 182 | yield* registerMigration({ id, options: authOptions, db, migrate }); |
| 183 | } |
| 184 | |
| 185 | // One Better Auth instance per execution (Worker event, DO call, |
| 186 | // Lambda invoke): the database input may hold sockets (pg/mysql |
| 187 | // pools), which are IoContext-pinned on workerd — they are acquired |
| 188 | // on the execution scope and released when the event settles. |
| 189 | const makeAuth = yield* makeExecutionMemo( |
| 190 | Effect.gen(function* () { |
| 191 | const database = yield* db.runtime; |
| 192 | const secretValue = Redacted.value(yield* secretAccessor); |
| 193 | |
| 194 | // Background tasks (better-auth's internal fire-and-forget work) |
| 195 | // are awaited by a finalizer on the execution scope — the bridges |
| 196 | // register it with `ctx.waitUntil` on workerd and settle it |
| 197 | // inline on Lambda. |
| 198 | const pending: Promise<unknown>[] = []; |
| 199 | yield* Effect.addFinalizer(() => |
| 200 | Effect.promise(() => Promise.allSettled(pending)), |
| 201 | ); |
| 202 | |
| 203 | const context = yield* Effect.context<RuntimeContext>(); |
| 204 | const runPromise = <A, E>( |
| 205 | effect: Effect.Effect<A, E, RuntimeContext>, |
| 206 | ): Promise<A> => |
| 207 | Effect.runPromise( |
| 208 | effect.pipe(Effect.provideContext(context)) as Effect.Effect<A, E>, |
| 209 | ); |
| 210 | |
| 211 | return betterAuth({ |
| 212 | ...authOptions, |
| 213 | database, |
| 214 | secret: secretValue, |
| 215 | ...(Option.isSome(secondary) |
| 216 | ? { |
| 217 | secondaryStorage: toPromiseStorage(secondary.value, runPromise), |
| 218 | } |
| 219 | : {}), |
| 220 | advanced: { |
| 221 | ...authOptions.advanced, |
| 222 | backgroundTasks: authOptions.advanced?.backgroundTasks ?? { |
| 223 | handler: (promise) => { |
| 224 | pending.push(promise); |
| 225 | }, |
| 226 | }, |
| 227 | }, |
| 228 | }) as unknown as Auth<AuthOptions<O>>; |
| 229 | }), |
| 230 | ); |
| 231 | |
| 232 | const api = makeApiProxy<Auth<AuthOptions<O>>>(makeAuth); |
| 233 | |
| 234 | const fetch: HttpEffect<RuntimeContext> = Effect.gen(function* () { |
| 235 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 236 | const webRequest = yield* HttpServerRequest.toWeb(request).pipe( |
| 237 | Effect.orDie, |
| 238 | ); |
| 239 | const auth = yield* makeAuth; |
| 240 | // `auth.handler` never rejects for API errors — they come back as |
| 241 | // error Responses, exactly what a pass-through route wants. |
| 242 | const response = yield* Effect.promise(() => auth.handler(webRequest)); |
| 243 | return HttpServerResponse.fromWeb(response); |
| 244 | }); |
| 245 | |
| 246 | const apiAny = api as Record< |
| 247 | string, |
| 248 | ( |
| 249 | input: unknown, |
| 250 | ) => Effect.Effect<unknown, BetterAuthApiError, RuntimeContext> |
| 251 | >; |
| 252 | const getSession = ((headers?: Headers) => |
| 253 | headers === undefined |
| 254 | ? Effect.gen(function* () { |
| 255 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 256 | const webRequest = yield* HttpServerRequest.toWeb(request).pipe( |
| 257 | Effect.orDie, |
| 258 | ); |
| 259 | return yield* apiAny.getSession!({ headers: webRequest.headers }); |
| 260 | }) |
| 261 | : apiAny.getSession!({ |
| 262 | headers, |
| 263 | })) as BetterAuthInstance<O>["getSession"]; |
| 264 | |
| 265 | return { |
| 266 | auth: makeAuth, |
| 267 | api, |
| 268 | fetch, |
| 269 | getSession, |
| 270 | Infer: undefined as unknown as BetterAuthInstance<O>["Infer"], |
| 271 | } satisfies BetterAuthInstance<O>; |
| 272 | }).pipe( |
| 273 | (effect) => |
| 274 | // Deploy-only requirements picked up by the init effect — the Random |
| 275 | // provider during stack evaluation, RuntimeContext for Output binds, |
| 276 | // Stack for migration Action registration — are provided ambiently by |
| 277 | // stack evaluation at deploy and by the bridge at runtime. They are |
| 278 | // erased from the public type (same doctrine as `makeExecutionMemo` |
| 279 | // erasing `Scope`); the one requirement the CALLER owns is `Database`. |
| 280 | effect as unknown as Effect.Effect< |
| 281 | BetterAuthInstance<O>, |
| 282 | never, |
| 283 | Database |
| 284 | >, |
| 285 | ); |