1import type { RuntimeContext } from "alchemy";
2import { Random } from "alchemy";
3import type { HttpEffect } from "alchemy/Http";
4import { makeExecutionMemo } from "alchemy/Runtime/ExecutionMemo";
5import { betterAuth, type Auth, type BetterAuthOptions } from "better-auth";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Redacted from "effect/Redacted";
9import * as HttpServerRequest from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import { makeApiProxy, type BetterAuthApi } from "./ApiProxy.ts";
12import { Database } from "./Database.ts";
13import type { BetterAuthApiError } from "./Errors.ts";
14import { 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 */
28export 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 */
71export 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. */
77export type Session<O extends BetterAuthProps> = Auth<
78 AuthOptions<O>
79>["$Infer"]["Session"];
80
81export 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 */
145export 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 );