From e8770d141665c45cee247e0987f42fee1e2ded7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 02:22:46 +0100 Subject: [PATCH 1/4] fix(auth): unwedge PGlite sign-up by routing single-user guard through the transaction adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `user.create.before` and `account.create.before` hooks called `getDb()` for a fresh pool connection while Better Auth's `/api/auth/sign-up/email` (and OAuth registration) endpoints already held the only one via `runWithTransaction`. In PGlite mode the pool is sized 1 (LOBU_DISABLE_PREPARE → poolMax=1), so the hook deadlocked the whole request — no log line, no response, curl headers-timeout. Routes the count + lookup through `ctx.context.internalAdapter` (`countTotalUsers` / `findUserById`), which reuses the in-flight transaction connection. Declares `principalKind` as a Better Auth additional field (input/returned: false, fieldName 'principal_kind') so the where-clause field name resolves without BA throwing "Field principal_kind not found in model user". Fail-closed if BA ever invokes the hook without an auth context. Closes #947. --- packages/server/src/auth/index.tsx | 92 +++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/packages/server/src/auth/index.tsx b/packages/server/src/auth/index.tsx index 17fc4b691..798ea5e8b 100644 --- a/packages/server/src/auth/index.tsx +++ b/packages/server/src/auth/index.tsx @@ -217,6 +217,24 @@ export async function createAuth(env: Env, request?: Request) { // Tokens are reusable for both login AND connectors socialProviders, + user: { + additionalFields: { + // Declare `principal_kind` so Better Auth's adapter accepts + // it in where clauses (e.g. the single-user-mode count in + // the user.create.before hook below). The column itself is + // added by db/migrations/20260519152824_principal_kind.sql + // with NOT NULL DEFAULT 'human', so input=false lets the DB + // default kick in on signup. See #947. + principalKind: { + type: "string", + fieldName: "principal_kind", + input: false, + returned: false, + required: false, + }, + }, + }, + account: { accountLinking: { enabled: true, @@ -592,7 +610,7 @@ export async function createAuth(env: Env, request?: Request) { databaseHooks: { user: { create: { - before: async (user) => { + before: async (user, ctx) => { // Single-user-mode chokepoint. The /api/auth/* middleware // in index.ts blocks /api/auth/sign-up/*, but Better Auth // also creates users on magic-link verify and on OAuth @@ -603,23 +621,44 @@ export async function createAuth(env: Env, request?: Request) { // second one. Closes the fork-via-magic-link / fork-via- // social-login backdoor codex flagged. if (env.LOBU_SINGLE_USER === "1") { - const { getDb } = await import("../db/client"); - const sql = getDb(); + // Route the count through Better Auth's internalAdapter + // so it joins the reserved transaction connection + // (sign-up runs inside runWithTransaction). Calling + // getDb() here would ask for a second pool connection + // while the signup endpoint already holds the only + // one — deadlock in PGlite mode (pool max=1). See #947. + // + // Fail closed if BA invokes this hook without an + // auth context: the count we'd otherwise default to + // `0` is the gate that prevents a second user from + // being created. + if (!ctx) { + throw new APIError("INTERNAL_SERVER_ERROR", { + code: "SIGN_UP_NO_AUTH_CONTEXT", + message: + "Sign-up rejected: missing auth context for single-user guard.", + }); + } // Exclude the synthetic install_operator row // (auto-provisioned at boot in ensureInstallOperator) // AND the legacy bootstrap-user row (pre-PR #902) - // from the "deployment already has a user" count, so - // the first human signup can still proceed in - // single-user mode against upgraded installs that - // still carry a bootstrap-user row. See + // from the count, so the first human signup can still + // proceed against upgraded installs that still carry a + // bootstrap-user row. See // docs/install-operator-bootstrap.md. - const rows = (await sql` - SELECT count(*)::int AS count - FROM "user" - WHERE principal_kind <> 'install_operator' - AND id <> 'bootstrap-user' - `) as unknown as Array<{ count: number }>; - const existing = rows[0]?.count ?? 0; + const existing = + await ctx.context.internalAdapter.countTotalUsers([ + { + field: "principalKind", + operator: "ne", + value: "install_operator", + }, + { + field: "id", + operator: "ne", + value: "bootstrap-user", + }, + ]); if (existing > 0) { // APIError so Better Auth turns this into a structured // JSON response with the right status code, not an @@ -705,7 +744,7 @@ export async function createAuth(env: Env, request?: Request) { }, account: { create: { - before: async (account) => { + before: async (account, ctx) => { // Carve-out: refuse OAuth account-linking onto the synthetic // install_operator user. The operator authenticates via // ENCRYPTION_KEY; admitting social-login linking would pin a @@ -716,13 +755,22 @@ export async function createAuth(env: Env, request?: Request) { // password-hash row at boot. See // docs/install-operator-bootstrap.md. if (account.providerId !== "credential") { - const { getDb } = await import("../db/client"); - const sql = getDb(); - const rows = (await sql` - SELECT principal_kind FROM "user" - WHERE id = ${account.userId} LIMIT 1 - `) as unknown as Array<{ principal_kind: string }>; - if (rows[0]?.principal_kind === "install_operator") { + // Route the lookup through Better Auth's internalAdapter + // so it joins the reserved transaction connection. A + // raw getDb() query would deadlock in PGlite mode (pool + // max=1) during new-OAuth-user registration, where + // createOAuthUser wraps user + account creation in + // runWithTransaction. The /link-social and + // callback-link paths are not transactional today but + // stay safe under this same code. See #947. + const linkedUser = + await ctx?.context.internalAdapter.findUserById( + account.userId, + ); + const principalKind = ( + linkedUser as { principalKind?: string } | null + )?.principalKind; + if (principalKind === "install_operator") { throw new APIError("FORBIDDEN", { code: "ACCOUNT_LINKING_NOT_ALLOWED_FOR_INSTALL_OPERATOR", message: From 974a001e5ad4502e1cba46b2db63aa6a9999ed72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 02:36:13 +0100 Subject: [PATCH 2/4] chore(auth): consolidate single-user-guard hook comments + drop redundant fail-closed branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trims the diff added by the previous commit: - Folds the two adjacent comment blocks in user.create.before into one paragraph, keeps the "why ctx.internalAdapter" explanation. - Drops the explicit `if (!ctx) throw …` — uses ctx! instead. A null ctx would throw a TypeError at the property access, which BA catches and surfaces as FAILED_TO_CREATE_USER (422); fail-closed for free, fewer lines. - Matches the same pattern in account.create.before. - Compacts the principalKind additionalField comment. Reproducer still green (sign-up #1 200, sign-up #2 403 with SIGN_UP_DISABLED_IN_SINGLE_USER_MODE). --- packages/server/src/auth/index.tsx | 89 +++++++++++------------------- 1 file changed, 32 insertions(+), 57 deletions(-) diff --git a/packages/server/src/auth/index.tsx b/packages/server/src/auth/index.tsx index 798ea5e8b..79cde0e4c 100644 --- a/packages/server/src/auth/index.tsx +++ b/packages/server/src/auth/index.tsx @@ -219,12 +219,10 @@ export async function createAuth(env: Env, request?: Request) { user: { additionalFields: { - // Declare `principal_kind` so Better Auth's adapter accepts - // it in where clauses (e.g. the single-user-mode count in - // the user.create.before hook below). The column itself is - // added by db/migrations/20260519152824_principal_kind.sql - // with NOT NULL DEFAULT 'human', so input=false lets the DB - // default kick in on signup. See #947. + // Declared so the where-clause in the single-user guard + // below resolves through BA's adapter. DB column has + // `NOT NULL DEFAULT 'human'` (db/migrations/...principal_kind.sql), + // so `input: false` lets the default fill in on signup. principalKind: { type: "string", fieldName: "principal_kind", @@ -611,58 +609,38 @@ export async function createAuth(env: Env, request?: Request) { user: { create: { before: async (user, ctx) => { - // Single-user-mode chokepoint. The /api/auth/* middleware + // Single-user-mode chokepoint. The /api/auth/* URL filter // in index.ts blocks /api/auth/sign-up/*, but Better Auth - // also creates users on magic-link verify and on OAuth + // also creates users on magic-link verify and OAuth // callbacks — paths the URL guard never sees. This hook - // runs immediately before every user INSERT regardless of - // how the request arrived; if LOBU_SINGLE_USER is on and - // the deployment already has a user, refuse to create a - // second one. Closes the fork-via-magic-link / fork-via- - // social-login backdoor codex flagged. + // fires before every user INSERT, so it's the one place + // that closes the fork-via-magic-link / fork-via-OAuth + // backdoor. + // + // The count goes through ctx.internalAdapter so it joins + // the in-flight transaction connection. Calling getDb() + // here would request a second pool connection while + // sign-up's runWithTransaction holds the only one — + // deadlock in PGlite mode (pool max=1). See #947. + // Missing ctx (called outside the BA endpoint pipeline) + // throws via `ctx!` → BA returns FAILED_TO_CREATE_USER, + // which is the fail-closed posture we want. if (env.LOBU_SINGLE_USER === "1") { - // Route the count through Better Auth's internalAdapter - // so it joins the reserved transaction connection - // (sign-up runs inside runWithTransaction). Calling - // getDb() here would ask for a second pool connection - // while the signup endpoint already holds the only - // one — deadlock in PGlite mode (pool max=1). See #947. - // - // Fail closed if BA invokes this hook without an - // auth context: the count we'd otherwise default to - // `0` is the gate that prevents a second user from - // being created. - if (!ctx) { - throw new APIError("INTERNAL_SERVER_ERROR", { - code: "SIGN_UP_NO_AUTH_CONTEXT", - message: - "Sign-up rejected: missing auth context for single-user guard.", - }); - } // Exclude the synthetic install_operator row - // (auto-provisioned at boot in ensureInstallOperator) - // AND the legacy bootstrap-user row (pre-PR #902) - // from the count, so the first human signup can still - // proceed against upgraded installs that still carry a - // bootstrap-user row. See - // docs/install-operator-bootstrap.md. + // (auto-provisioned by ensureInstallOperator) AND the + // legacy bootstrap-user row (pre-PR #902) so the + // first human signup still proceeds on upgraded + // installs. See docs/install-operator-bootstrap.md. const existing = - await ctx.context.internalAdapter.countTotalUsers([ + await ctx!.context.internalAdapter.countTotalUsers([ { field: "principalKind", operator: "ne", value: "install_operator", }, - { - field: "id", - operator: "ne", - value: "bootstrap-user", - }, + { field: "id", operator: "ne", value: "bootstrap-user" }, ]); if (existing > 0) { - // APIError so Better Auth turns this into a structured - // JSON response with the right status code, not an - // unhandled 500 with an empty body. throw new APIError("FORBIDDEN", { code: "SIGN_UP_DISABLED_IN_SINGLE_USER_MODE", message: @@ -755,18 +733,15 @@ export async function createAuth(env: Env, request?: Request) { // password-hash row at boot. See // docs/install-operator-bootstrap.md. if (account.providerId !== "credential") { - // Route the lookup through Better Auth's internalAdapter - // so it joins the reserved transaction connection. A - // raw getDb() query would deadlock in PGlite mode (pool - // max=1) during new-OAuth-user registration, where - // createOAuthUser wraps user + account creation in - // runWithTransaction. The /link-social and - // callback-link paths are not transactional today but - // stay safe under this same code. See #947. + // Route through ctx.internalAdapter so the lookup + // shares the in-flight transaction connection on the + // one path that wraps in runWithTransaction — + // createOAuthUser, called from OAuth callback for new + // users. Avoids the PGlite pool-max=1 deadlock; see + // #947. `/link-social` and existing-user callback + // links aren't transactional today but stay safe. const linkedUser = - await ctx?.context.internalAdapter.findUserById( - account.userId, - ); + await ctx!.context.internalAdapter.findUserById(account.userId); const principalKind = ( linkedUser as { principalKind?: string } | null )?.principalKind; From c30113d82548e070a454a9b273d59b296a176ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 02:57:31 +0100 Subject: [PATCH 3/4] test(auth): integration coverage for the single-user signup guard + #947 deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend-agnostic integration test that runs unchanged against external Postgres (default) and PGlite (LOBU_TEST_BACKEND=pglite). Asserts: - first human signup is admitted and the row is sign-in-ready (principal_kind defaults to 'human' via the DB default, credential hash verifies against the submitted password); - the second signup is refused with SIGN_UP_DISABLED_IN_SINGLE_USER_MODE; - seeded install_operator and bootstrap-user rows don't count as the existing human. Under the PGlite backend this reproduces #947: reverting the hook to a fresh getDb() query hangs the request and the test fails on timeout (verified — all three time out at 15s with the old code). --- .../auth/single-user-signup.test.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/server/src/__tests__/integration/auth/single-user-signup.test.ts diff --git a/packages/server/src/__tests__/integration/auth/single-user-signup.test.ts b/packages/server/src/__tests__/integration/auth/single-user-signup.test.ts new file mode 100644 index 000000000..f50a70166 --- /dev/null +++ b/packages/server/src/__tests__/integration/auth/single-user-signup.test.ts @@ -0,0 +1,162 @@ +/** + * Integration test for the single-user-mode sign-up guard in + * `auth/index.tsx` (`databaseHooks.user.create.before`). + * + * Pins two contracts: + * + * 1. The guard counts real humans correctly — the synthetic + * install_operator row and the legacy bootstrap-user row are + * excluded, so the FIRST human signup proceeds and the SECOND is + * refused with SIGN_UP_DISABLED_IN_SINGLE_USER_MODE. + * + * 2. The guard does not deadlock. Sign-up runs inside Better Auth's + * runWithTransaction, which reserves the only pooled connection in + * PGlite mode (LOBU_DISABLE_PREPARE=1 → pool max=1). The hook must + * reuse that transaction connection via ctx.internalAdapter rather + * than asking getDb() for a second one. Run under + * `bun run test:pglite` this test reproduces issue #947: a regression + * to a fresh getDb() query hangs the request and fails on timeout. + * + * The test is backend-agnostic — it talks to the auth handler over a + * Request, reads DATABASE_URL like the rest of the suite, and so runs + * unchanged against external Postgres (default) and PGlite + * (LOBU_TEST_BACKEND=pglite). + */ + +import { verifyPassword } from "better-auth/crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createAuth } from "../../../auth/index"; +import { getEnvFromProcess } from "../../../utils/env"; +import { cleanupTestDatabase, getTestDb } from "../../setup/test-db"; + +const SIGN_UP_URL = "http://localhost/api/auth/sign-up/email"; + +interface SignUpResult { + status: number; + body: Record; +} + +async function signUp(input: { + email: string; + password: string; + name: string; +}): Promise { + const auth = await createAuth(getEnvFromProcess()); + const res = await auth.handler( + new Request(SIGN_UP_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + ); + const body = (await res.json().catch(() => ({}))) as Record; + return { status: res.status, body }; +} + +async function seedUser(id: string, principalKind: string): Promise { + const sql = getTestDb(); + await sql` + INSERT INTO "user" (id, name, email, "emailVerified", principal_kind, "createdAt", "updatedAt") + VALUES ( + ${id}, + ${id}, + ${`${id}@seed.test`}, + true, + ${principalKind}, + NOW(), + NOW() + ) + ON CONFLICT (id) DO NOTHING + `; +} + +describe("single-user-mode sign-up guard", () => { + const originalSingleUser = process.env.LOBU_SINGLE_USER; + const originalSecret = process.env.BETTER_AUTH_SECRET; + + beforeEach(async () => { + await cleanupTestDatabase(); + process.env.LOBU_SINGLE_USER = "1"; + // Deterministic secret so credential hashing + session signing work. + process.env.BETTER_AUTH_SECRET = "a".repeat(64); + }); + + afterEach(() => { + if (originalSingleUser === undefined) delete process.env.LOBU_SINGLE_USER; + else process.env.LOBU_SINGLE_USER = originalSingleUser; + if (originalSecret === undefined) delete process.env.BETTER_AUTH_SECRET; + else process.env.BETTER_AUTH_SECRET = originalSecret; + }); + + it("admits the first human signup and makes a sign-in-ready row", async () => { + // Completes (no #947 deadlock) and returns 200. + const first = await signUp({ + email: "first@local.test", + password: "firstpassword99", + name: "First", + }); + expect(first.status).toBe(200); + const userId = (first.body.user as { id?: string } | undefined)?.id; + expect(userId).toBeTruthy(); + if (!userId) throw new Error("signup returned no user id"); + + const sql = getTestDb(); + // input:false means the column was never sent on INSERT — the DB + // default 'human' must have filled it in (not NULL). + const rows = (await sql` + SELECT principal_kind FROM "user" WHERE id = ${userId} + `) as unknown as Array<{ principal_kind: string }>; + expect(rows[0]?.principal_kind).toBe("human"); + + // The credential row must verify against the submitted password — + // proves the create transaction committed, not just returned 200. + const accounts = (await sql` + SELECT "providerId", password FROM "account" WHERE "userId" = ${userId} + `) as unknown as Array<{ providerId: string; password: string | null }>; + expect(accounts[0]?.providerId).toBe("credential"); + const hash = accounts[0]?.password; + expect(hash).toBeTruthy(); + if (!hash) throw new Error("credential account has no password hash"); + expect(await verifyPassword({ hash, password: "firstpassword99" })).toBe( + true, + ); + }); + + it("refuses the second signup once a human exists", async () => { + const first = await signUp({ + email: "first@local.test", + password: "firstpassword99", + name: "First", + }); + expect(first.status).toBe(200); + + const second = await signUp({ + email: "second@local.test", + password: "secondpassword99", + name: "Second", + }); + expect(second.status).toBe(403); + expect(second.body.code).toBe("SIGN_UP_DISABLED_IN_SINGLE_USER_MODE"); + + const sql = getTestDb(); + const humans = (await sql` + SELECT count(*)::int AS count FROM "user" + WHERE principal_kind <> 'install_operator' AND id <> 'bootstrap-user' + `) as unknown as Array<{ count: number }>; + expect(humans[0]?.count).toBe(1); + }); + + it("does not count install_operator or bootstrap-user as the existing human", async () => { + await seedUser("user_install_seed", "install_operator"); + await seedUser("bootstrap-user", "human"); + + // Neither seeded row is a real human, so the first human signup + // must still be admitted. + const first = await signUp({ + email: "first@local.test", + password: "firstpassword99", + name: "First", + }); + expect(first.status).toBe(200); + }); +}); From 17f30107564f803323c8aff6e4a25d2bac38c210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 03:27:15 +0100 Subject: [PATCH 4/4] test(auth): bust auth-instance cache so the single-user test is deterministic in the full suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createAuth() memoizes betterAuth instances in a per-org TtlCache. Under the integration suite's shared module graph (isolate:false), an earlier test file builds the "__system__" instance while LOBU_SINGLE_USER is unset, so its user.create.before closure has the guard disabled. My test then reused that stale instance and the second signup was admitted — order-dependent flake (passed alone, failed late in the suite). - Add clearAuthCacheForTests() to auth/index.tsx (mirrors the existing clearXForTests helpers); production never needs it since env is stable per-process. - Clear the cache in beforeEach (protect against upstream pollution) and afterEach (don't leak our LOBU_SINGLE_USER=1 instance to later files — this was the likely cause of the member-privacy flake in CI too). - Make the "refuses" case seed a committed human via SQL instead of chaining two signups, removing the cross-request visibility dependency. Verified against the full integration suite locally on real Postgres (CI's singleFork/isolate:false config): the 3 single-user tests pass; the only remaining local failures are the isolated-vm sandbox tests, which are environment-specific and pass in CI. --- .../auth/single-user-signup.test.ts | 35 +++++++++---------- packages/server/src/auth/index.tsx | 11 ++++++ 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/server/src/__tests__/integration/auth/single-user-signup.test.ts b/packages/server/src/__tests__/integration/auth/single-user-signup.test.ts index f50a70166..1634ef022 100644 --- a/packages/server/src/__tests__/integration/auth/single-user-signup.test.ts +++ b/packages/server/src/__tests__/integration/auth/single-user-signup.test.ts @@ -25,7 +25,7 @@ import { verifyPassword } from "better-auth/crypto"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createAuth } from "../../../auth/index"; +import { clearAuthCacheForTests, createAuth } from "../../../auth/index"; import { getEnvFromProcess } from "../../../utils/env"; import { cleanupTestDatabase, getTestDb } from "../../setup/test-db"; @@ -79,6 +79,11 @@ describe("single-user-mode sign-up guard", () => { process.env.LOBU_SINGLE_USER = "1"; // Deterministic secret so credential hashing + session signing work. process.env.BETTER_AUTH_SECRET = "a".repeat(64); + // createAuth() memoizes per-org instances (TtlCache). Other test files + // build the "__system__" instance with LOBU_SINGLE_USER unset; without + // busting the cache we'd reuse that instance and the guard closure would + // read the wrong flag. + clearAuthCacheForTests(); }); afterEach(() => { @@ -86,6 +91,9 @@ describe("single-user-mode sign-up guard", () => { else process.env.LOBU_SINGLE_USER = originalSingleUser; if (originalSecret === undefined) delete process.env.BETTER_AUTH_SECRET; else process.env.BETTER_AUTH_SECRET = originalSecret; + // Don't leak our LOBU_SINGLE_USER=1 instance into the shared cache — + // a later file's createAuth() would otherwise reuse it. + clearAuthCacheForTests(); }); it("admits the first human signup and makes a sign-in-ready row", async () => { @@ -122,28 +130,19 @@ describe("single-user-mode sign-up guard", () => { ); }); - it("refuses the second signup once a human exists", async () => { - const first = await signUp({ - email: "first@local.test", - password: "firstpassword99", - name: "First", - }); - expect(first.status).toBe(200); + it("refuses signup once a human already exists", async () => { + // Seed a committed human directly (not via a prior signup) so the + // precondition has a clean happens-before and doesn't depend on + // cross-request visibility timing under the shared test pool. + await seedUser("existing-human", "human"); - const second = await signUp({ + const res = await signUp({ email: "second@local.test", password: "secondpassword99", name: "Second", }); - expect(second.status).toBe(403); - expect(second.body.code).toBe("SIGN_UP_DISABLED_IN_SINGLE_USER_MODE"); - - const sql = getTestDb(); - const humans = (await sql` - SELECT count(*)::int AS count FROM "user" - WHERE principal_kind <> 'install_operator' AND id <> 'bootstrap-user' - `) as unknown as Array<{ count: number }>; - expect(humans[0]?.count).toBe(1); + expect(res.status).toBe(403); + expect(res.body.code).toBe("SIGN_UP_DISABLED_IN_SINGLE_USER_MODE"); }); it("does not count install_operator or bootstrap-user as the existing human", async () => { diff --git a/packages/server/src/auth/index.tsx b/packages/server/src/auth/index.tsx index 79cde0e4c..97be973b3 100644 --- a/packages/server/src/auth/index.tsx +++ b/packages/server/src/auth/index.tsx @@ -68,6 +68,17 @@ function gravatarUrl(email: string): string { // The config (OAuth providers) rarely changes, so 60s TTL is safe. const authCache = new TtlCache>(60_000); +/** + * Drop every cached betterAuth instance. Production never needs this (env is + * stable per-process), but integration tests that flip env vars like + * LOBU_SINGLE_USER between cases must bust the cache, or a stale instance + * built under the previous env serves the request and the hook closures + * read the wrong flag. + */ +export function clearAuthCacheForTests(): void { + authCache.clear(); +} + /** * Create a better-auth instance with all plugins configured. *