From c0692caafc3cd79273c2f4956ca28bb9b110cd0d Mon Sep 17 00:00:00 2001 From: froQ Date: Tue, 27 Oct 2026 15:15:41 +0000 Subject: [PATCH 1/2] feat(auth): infer `AuthUser` and `AuthSession` from `auth.ts` Fixes #567 ## Summary `AuthUser` and `AuthSession` were always Better Auth's base shapes. Fields from plugins and `user.additionalFields` exist at runtime but were missing from `c.get('user')`, `getUser()`, `requireAuth()`, and the session helpers. ## Changes - Make `AuthUser` and `AuthSession` mergeable interfaces - Generate `.void/auth.d.ts` from the app `auth.ts` (same pattern as `.void/env.d.ts`) - Wire generation into prepare and the routing plugin; remove the file when auth is off - List `auth.d.ts` in `.void/tsconfig.json` only after the file exists - Note inferred fields in the auth API docs ## Testing - Unit tests for codegen and for when `auth.d.ts` is listed in tsconfig - `prepareProject` write/delete lifecycle test - Integration `tsc` with `admin()` and `additionalFields` on `c.get`, `getUser`, `getSession`, and `requireAuth` --- docs/reference/api/auth.md | 4 +- packages/void/src/codegen/auth-types.ts | 70 +++++++++++++++++ packages/void/src/codegen/prepare.ts | 10 +++ packages/void/src/codegen/project-tsconfig.ts | 2 + packages/void/src/plugin-routing.ts | 37 ++++++++- packages/void/src/runtime/auth.ts | 4 +- .../integration/auth-types-typecheck.test.ts | 77 +++++++++++++++++++ .../void/test/unit/auth-types-codegen.test.ts | 60 +++++++++++++++ .../void/test/unit/auth-types-prepare.test.ts | 68 ++++++++++++++++ .../void/test/unit/project-tsconfig.test.ts | 17 ++++ 10 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 packages/void/src/codegen/auth-types.ts create mode 100644 packages/void/test/integration/auth-types-typecheck.test.ts create mode 100644 packages/void/test/unit/auth-types-codegen.test.ts create mode 100644 packages/void/test/unit/auth-types-prepare.test.ts diff --git a/docs/reference/api/auth.md b/docs/reference/api/auth.md index a759edf75..dba99fa6d 100644 --- a/docs/reference/api/auth.md +++ b/docs/reference/api/auth.md @@ -79,7 +79,7 @@ function requireAuth(c: CloudContext): AuthUser; ## `AuthUser` {#authuser} -Better Auth user shape re-exported by Void. +Better Auth user shape re-exported by Void. When the project has an `auth.ts` that exports `defineAuth(...)`, Void generates `.void/auth.d.ts` (listed in `.void/tsconfig.json`) so `AuthUser` also includes fields from Better Auth plugins and `user.additionalFields` in that config — for example `role` from `admin()` or a custom `plan` field. ```ts interface AuthUser { @@ -95,7 +95,7 @@ interface AuthUser { ## `AuthSession` {#authsession} -Better Auth session shape re-exported by Void. +Better Auth session shape re-exported by Void. With a generated `.void/auth.d.ts`, `AuthSession` picks up session fields contributed by plugins in `auth.ts` (for example `impersonatedBy` from `admin()`). ```ts interface AuthSession { diff --git a/packages/void/src/codegen/auth-types.ts b/packages/void/src/codegen/auth-types.ts new file mode 100644 index 000000000..d23131cb9 --- /dev/null +++ b/packages/void/src/codegen/auth-types.ts @@ -0,0 +1,70 @@ +import { existsSync, rmSync } from 'node:fs'; +import { join, relative } from 'pathslash'; +import { writeFileIfChanged } from './write-if-changed.ts'; + +export type AuthTypesResult = { + dts: string; +}; + +/** + * Generate `.void/auth.d.ts` content. Augments `void/auth`'s `AuthUser` and + * `AuthSession` interfaces with Better Auth inference from the app's + * `defineAuth` config (plugins, `user.additionalFields`, etc.). + * + * Uses `typeof import(...)` on the auth config file so TypeScript handles the + * inference — codegen never analyses types itself. + * + * @param authFileAbs Absolute path to the user's auth config file. + * @param outDir Absolute path to the project's `.void/` directory. + */ +export function generateAuthTypes(authFileAbs: string, outDir: string): AuthTypesResult { + const importPath = relative(outDir, authFileAbs); + const lines: Array = []; + + lines.push('// Auto-generated by void — do not edit'); + lines.push('export {}'); + lines.push(''); + lines.push('import type { Auth } from "better-auth";'); + lines.push(''); + lines.push(`type _VoidAuthModule = typeof import("${importPath}");`); + lines.push('type _VoidAuthConfig = _VoidAuthModule["default"];'); + lines.push( + 'type _VoidAuthOptions = _VoidAuthConfig extends (...args: never[]) => infer R ? R : _VoidAuthConfig;', + ); + lines.push('type _InferredUser = Auth<_VoidAuthOptions>["$Infer"]["Session"]["user"];'); + lines.push('type _InferredSession = Auth<_VoidAuthOptions>["$Infer"]["Session"]["session"];'); + lines.push(''); + lines.push('declare module "void/auth" {'); + lines.push(' interface AuthUser extends _InferredUser {}'); + lines.push(' interface AuthSession extends _InferredSession {}'); + lines.push('}'); + lines.push(''); + + return { dts: lines.join('\n') }; +} + +export function authTypesPath(outDir: string): string { + return join(outDir, 'auth.d.ts'); +} + +/** Remove `.void/auth.d.ts` when auth is disabled or the config file is gone. */ +export function removeAuthTypesIfPresent(outDir: string): boolean { + const filePath = authTypesPath(outDir); + if (!existsSync(filePath)) { + return false; + } + rmSync(filePath, { force: true }); + return true; +} + +/** + * Write or remove `.void/auth.d.ts` from the current auth config path. + * Returns whether the file on disk changed. + */ +export function syncAuthTypesFile(authConfigPath: string | null, outDir: string): boolean { + if (!authConfigPath) { + return removeAuthTypesIfPresent(outDir); + } + const { dts } = generateAuthTypes(authConfigPath, outDir); + return writeFileIfChanged(authTypesPath(outDir), dts); +} diff --git a/packages/void/src/codegen/prepare.ts b/packages/void/src/codegen/prepare.ts index e6e780c37..d312afd85 100644 --- a/packages/void/src/codegen/prepare.ts +++ b/packages/void/src/codegen/prepare.ts @@ -8,6 +8,7 @@ import { getDatabaseCasing, readConfig } from '../config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; import { loadLocalEnv } from '../shared/env.ts'; import { generateDbDts } from './db-types.ts'; +import { generateAuthTypes, removeAuthTypesIfPresent } from './auth-types.ts'; import { findEnvFileForPaths, generateEnvTypes } from './env-types.ts'; import { ensureProjectTsconfig } from './project-tsconfig.ts'; import { generateQueueTypes } from './queue-types.ts'; @@ -55,6 +56,15 @@ export async function prepareProject(root: string): Promise 0 || (scan.websockets?.length ?? 0) > 0 || scan.pages.pages.length > 0; if (hasRouteTypes) { diff --git a/packages/void/src/codegen/project-tsconfig.ts b/packages/void/src/codegen/project-tsconfig.ts index 7d602a9ab..bd2bad853 100644 --- a/packages/void/src/codegen/project-tsconfig.ts +++ b/packages/void/src/codegen/project-tsconfig.ts @@ -39,12 +39,14 @@ export function createProjectTsconfig( const includeQueues = existsSync(pathsConfig.queuesDir); const includeMd = existsSync(join(outDir, 'md.d.ts')); const includeEnv = existsSync(join(outDir, 'env.d.ts')); + const includeAuth = existsSync(join(outDir, 'auth.d.ts')); const files = [ ...(includeRoutes ? ['routes.d.ts'] : []), ...(hasDb ? ['db.d.ts'] : []), ...(includeQueues ? ['queues.d.ts'] : []), ...(includeMd ? ['md.d.ts'] : []), ...(includeEnv ? ['env.d.ts'] : []), + ...(includeAuth ? ['auth.d.ts'] : []), ]; const tsconfig: ProjectTsconfig = { files }; diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index f19b029d3..9d7ed83fc 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -31,11 +31,14 @@ import { import { resolveDistDir, resolveWorkerDirName } from './cli/build-output-dir.ts'; import { lintRoutingPhases, mergeRoutingRules } from './cli/headers.ts'; import { stampClientAssetCachePolicy } from './client-asset-manifest.ts'; +import { removeAuthTypesIfPresent, syncAuthTypesFile } from './codegen/auth-types.ts'; import { findEnvFileForPaths } from './codegen/env-types.ts'; +import { ensureProjectTsconfig } from './codegen/project-tsconfig.ts'; import { generateQueueTypes } from './codegen/queue-types.ts'; import { generateRouteTypes } from './codegen/route-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; -import { readConfig, getDatabaseDialect } from './config.ts'; +import { readConfig, getDatabaseCasing, getDatabaseDialect } from './config.ts'; +import { discoverSchema } from './drizzle/discover.ts'; import { collectPages, collectRevalidate, hasRevalidate } from './isr-config.ts'; import { readProjectPaths } from './project-paths.ts'; import { readDeployBuildQueueNames } from './queues/cloudflare-names.ts'; @@ -198,6 +201,22 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug writeFileIfChanged(join(outDir, 'queues.d.ts'), dts); } + function syncProjectTsconfig(): void { + if (!scan) { + return; + } + const analysis = options.getApplicationAnalysis(routeEnvironment); + const config = readConfig(root); + const schema = discoverSchema(root, paths); + const hasRouteTypes = + scan.routes.length > 0 || + (scan.websockets?.length ?? 0) > 0 || + scan.pages.pages.length > 0; + const hasDbRuntime = + schema != null || analysis.authEnabled || getDatabaseCasing(config) !== undefined; + ensureProjectTsconfig(root, hasRouteTypes, hasDbRuntime, schema != null, { paths }); + } + async function rescan(refresh = false) { const analysis = options.getApplicationAnalysis(routeEnvironment, refresh); const nextScan = (await completeApplicationAnalysis(analysis)).scan; @@ -321,7 +340,13 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug 'routes.d.ts', 'queues.d.ts', ...(options.target ? ['app.ts', 'index.ts'] : ['entry.ts']), - ...(compileOptions.auth ? ['better-auth-db.ts', 'better-auth-schema.ts'] : []), + ...(compileOptions.auth + ? [ + 'better-auth-db.ts', + 'better-auth-schema.ts', + ...(compileOptions.auth.authConfigPath ? ['auth.d.ts'] : []), + ] + : []), ]; const parts: Array = []; for (const file of files) { @@ -358,6 +383,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug async function writeAuthDatabaseFiles(): Promise { if (!compileOptions.auth) { + if (removeAuthTypesIfPresent(outDir)) { + syncProjectTsconfig(); + } return; } @@ -382,6 +410,11 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug drizzleAdapterPath, }), ); + + const authConfigPath = compileOptions.auth.authConfigPath; + if (syncAuthTypesFile(authConfigPath, outDir)) { + syncProjectTsconfig(); + } } let resolvedConfig: import('vite').ResolvedConfig | null = null; diff --git a/packages/void/src/runtime/auth.ts b/packages/void/src/runtime/auth.ts index 88ed87eed..98fc01cbb 100644 --- a/packages/void/src/runtime/auth.ts +++ b/packages/void/src/runtime/auth.ts @@ -3,8 +3,8 @@ import type { BetterAuthOptions, Session, User } from 'better-auth'; import type { Context, Env } from 'hono'; import { HTTPException } from 'hono/http-exception'; -export type AuthUser = User; -export type AuthSession = Session; +export interface AuthUser extends User {} +export interface AuthSession extends Session {} export type AuthState = { session: AuthSession; diff --git a/packages/void/test/integration/auth-types-typecheck.test.ts b/packages/void/test/integration/auth-types-typecheck.test.ts new file mode 100644 index 000000000..a262f3bd9 --- /dev/null +++ b/packages/void/test/integration/auth-types-typecheck.test.ts @@ -0,0 +1,77 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { generateAuthTypes } from '../../src/codegen/auth-types.ts'; + +const workspaceRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const roots: Array = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('generated auth types in a consumer project', () => { + it('types plugin fields and additionalFields on user, session, and context', () => { + const root = mkdtempSync(join(workspaceRoot, 'playground/kitchen-sink/.tmp-auth-types-')); + roots.push(root); + const authFile = join(root, 'auth.ts'); + const outDir = join(root, '.void'); + mkdirSync(outDir); + writeFileSync( + authFile, + 'import { admin } from "better-auth/plugins/admin";\n' + + 'import { defineAuth } from "void/auth";\n' + + 'export default defineAuth(({ defaults }) => ({\n' + + ' ...defaults,\n' + + ' plugins: [admin()],\n' + + ' user: { additionalFields: { plan: { type: "string", required: false } } },\n' + + '}));\n', + ); + writeFileSync(join(outDir, 'auth.d.ts'), generateAuthTypes(authFile, outDir).dts); + writeFileSync( + join(root, 'handler.ts'), + 'import { defineHandler } from "void";\n' + + 'import { getUser, getSession, requireAuth } from "void/auth";\n' + + 'export const loader = defineHandler((c) => {\n' + + ' const role = c.get("user")?.role;\n' + + ' const plan = getUser()?.plan;\n' + + ' const impersonatedBy = c.get("session")?.impersonatedBy;\n' + + ' const sessionImpersonatedBy = getSession()?.session.impersonatedBy;\n' + + ' const requiredRole = requireAuth(c).role;\n' + + ' return { role, plan, impersonatedBy, sessionImpersonatedBy, requiredRole };\n' + + '});\n', + ); + const betterAuthPkg = join(workspaceRoot, 'packages/void/node_modules/better-auth'); + const betterAuthRel = relative(root, betterAuthPkg); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + skipLibCheck: true, + noEmit: true, + paths: { + 'better-auth': [betterAuthRel], + 'better-auth/plugins/admin': [ + `${betterAuthRel}/dist/plugins/admin/index.d.mts`, + ], + }, + }, + files: ['auth.ts', '.void/auth.d.ts', 'handler.ts'], + }), + ); + + const result = spawnSync( + process.execPath, + [join(workspaceRoot, 'node_modules/typescript/lib/tsc.js'), '--project', root], + { encoding: 'utf8' }, + ); + expect(result.status, result.stdout + result.stderr).toBe(0); + }); +}); diff --git a/packages/void/test/unit/auth-types-codegen.test.ts b/packages/void/test/unit/auth-types-codegen.test.ts new file mode 100644 index 000000000..b84c5b4c0 --- /dev/null +++ b/packages/void/test/unit/auth-types-codegen.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vite-plus/test'; +import { + generateAuthTypes, + removeAuthTypesIfPresent, + syncAuthTypesFile, +} from '../../src/codegen/auth-types.ts'; + +function withTempProject(setup: (root: string) => void): { + root: string; + outDir: string; + cleanup: () => void; +} { + const root = mkdtempSync(join(tmpdir(), 'void-auth-codegen-')); + setup(root); + const outDir = join(root, '.void'); + mkdirSync(outDir, { recursive: true }); + return { + root, + outDir, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +describe('syncAuthTypesFile', () => { + it('writes auth.d.ts when a config path is provided and removes it when null', () => { + const { root, outDir, cleanup } = withTempProject((r) => { + writeFileSync(join(r, 'auth.ts'), 'export default {};\n'); + }); + try { + expect(syncAuthTypesFile(join(root, 'auth.ts'), outDir)).toBe(true); + expect(syncAuthTypesFile(join(root, 'auth.ts'), outDir)).toBe(false); + expect(syncAuthTypesFile(null, outDir)).toBe(true); + expect(removeAuthTypesIfPresent(outDir)).toBe(false); + } finally { + cleanup(); + } + }); +}); + +describe('generateAuthTypes', () => { + it('emits a module that augments void/auth AuthUser and AuthSession', () => { + const { root, outDir, cleanup } = withTempProject((r) => { + writeFileSync(join(r, 'auth.ts'), 'export default {};'); + }); + try { + const { dts } = generateAuthTypes(join(root, 'auth.ts'), outDir); + expect(dts).toContain('export {}'); + expect(dts).toContain('declare module "void/auth"'); + expect(dts).toContain('interface AuthUser extends _InferredUser'); + expect(dts).toContain('interface AuthSession extends _InferredSession'); + expect(dts).toContain('typeof import("../auth.ts")'); + expect(dts).toContain('Auth<_VoidAuthOptions>["$Infer"]["Session"]["user"]'); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/void/test/unit/auth-types-prepare.test.ts b/packages/void/test/unit/auth-types-prepare.test.ts new file mode 100644 index 000000000..e4653c91b --- /dev/null +++ b/packages/void/test/unit/auth-types-prepare.test.ts @@ -0,0 +1,68 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { createProjectTsconfig } from '../../src/codegen/project-tsconfig.ts'; + +const workspaceRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const roots: Array = []; + +function runPrepare(root: string): void { + execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + 'const { prepareProject } = await import(process.argv[1]); await prepareProject(process.argv[2]);', + fileURLToPath(new URL('../../src/codegen/prepare.ts', import.meta.url)), + root, + ], + { stdio: 'pipe' }, + ); +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('auth types prepare lifecycle', () => { + it('writes auth.d.ts and updates tsconfig after prepare; removes both when auth.ts is gone', () => { + const root = mkdtempSync(join(tmpdir(), 'void-auth-prepare-')); + roots.push(root); + mkdirSync(join(root, '.void'), { recursive: true }); + const voidAuthTypes = relative(root, join(workspaceRoot, 'packages/void/src/runtime/auth.ts')); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { paths: { 'void/auth': [voidAuthTypes] } }, + }), + ); + writeFileSync( + join(root, 'auth.ts'), + 'import type { VoidAuthConfig } from "void/auth";\nexport default {} satisfies VoidAuthConfig;\n', + ); + + expect(createProjectTsconfig(root).files).not.toContain('auth.d.ts'); + + runPrepare(root); + + expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(true); + const withAuth = JSON.parse(readFileSync(join(root, '.void', 'tsconfig.json'), 'utf8')) as { + files: Array; + }; + expect(withAuth.files).toContain('auth.d.ts'); + + rmSync(join(root, 'auth.ts')); + runPrepare(root); + + expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(false); + const withoutAuth = JSON.parse(readFileSync(join(root, '.void', 'tsconfig.json'), 'utf8')) as { + files: Array; + }; + expect(withoutAuth.files).not.toContain('auth.d.ts'); + }, 30_000); +}); diff --git a/packages/void/test/unit/project-tsconfig.test.ts b/packages/void/test/unit/project-tsconfig.test.ts index 01eb329cb..b198c5a8d 100644 --- a/packages/void/test/unit/project-tsconfig.test.ts +++ b/packages/void/test/unit/project-tsconfig.test.ts @@ -75,6 +75,23 @@ describe('ensureProjectTsconfig', () => { expect(statSync(tsconfigPath).mtimeMs).not.toBe(oldTime.getTime()); }); + it('lists auth.d.ts only when the generated file already exists', () => { + writeFileSync( + join(root, 'auth.ts'), + 'import { defineAuth } from "void/auth";\nexport default defineAuth({});\n', + ); + ensureProjectTsconfig(root); + expect(readTsconfig().files).not.toContain('auth.d.ts'); + + writeFileSync(join(root, '.void', 'auth.d.ts'), 'export {};\n'); + ensureProjectTsconfig(root); + expect(readTsconfig().files).toContain('auth.d.ts'); + + rmSync(join(root, '.void', 'auth.d.ts')); + ensureProjectTsconfig(root); + expect(readTsconfig().files).not.toContain('auth.d.ts'); + }); + it('combines routes, queues, and md when all present', () => { mkdirSync(join(root, 'routes'), { recursive: true }); mkdirSync(join(root, 'queues'), { recursive: true }); From ecc7399588b675525ec2609e5054760bcf697a61 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 15:36:50 +0800 Subject: [PATCH 2/2] fix(auth): keep inferred auth types resolvable and assignable - Fill a `VoidAuthTypes` registry from `.void/auth.d.ts` instead of augmenting `AuthUser`/`AuthSession` interfaces. They stay type aliases, so they remain assignable to `Record` and a function config that returns different shapes per branch gives a valid union. - Reach `better-auth` through `void/auth`'s `InferAuthSession`, so apps that only depend on `void` (pnpm strict) keep the inferred fields. - Fall back to the base shapes for an untyped `any` auth config. - Only add or drop `auth.d.ts` in the existing `.void/tsconfig.json` from the dev plugin, instead of recomputing the routes/db entries. - Test field types, a negative case, a branching config, a JS config and the dev plugin path; document the circular-reference trap inside `auth.ts`. Co-Authored-By: Claude Opus 5.5 --- docs/guide/auth.md | 2 +- docs/reference/api/auth.md | 8 +- packages/void/src/codegen/auth-types.ts | 25 +-- packages/void/src/codegen/prepare.ts | 10 +- packages/void/src/codegen/project-tsconfig.ts | 24 ++- packages/void/src/plugin-routing.ts | 29 +--- packages/void/src/runtime/auth.ts | 33 +++- .../integration/auth-types-typecheck.test.ts | 143 ++++++++++++------ .../void/test/unit/auth-types-codegen.test.ts | 15 +- .../void/test/unit/auth-types-prepare.test.ts | 28 +--- .../void/test/unit/plugin-routing.test.ts | 37 +++++ 11 files changed, 237 insertions(+), 117 deletions(-) diff --git a/docs/guide/auth.md b/docs/guide/auth.md index fac1aa526..f7a0ebae1 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -119,7 +119,7 @@ import { getSession, getUser, requireAuth } from 'void/auth'; - `getSession()` returns `{ user, session } | null` - `requireAuth(c)` returns the authenticated user or throws `401` -Import `AuthUser` and `AuthSession` from `void/auth` for type annotations. See their fields in the [API reference](../reference/api/auth.md#authuser). +Import `AuthUser` and `AuthSession` from `void/auth` for type annotations. They include the fields your Better Auth plugins and `user.additionalFields` add, such as `role` from `admin()`. See their fields in the [API reference](../reference/api/auth.md#authuser). ## Behavior diff --git a/docs/reference/api/auth.md b/docs/reference/api/auth.md index dba99fa6d..107f373c7 100644 --- a/docs/reference/api/auth.md +++ b/docs/reference/api/auth.md @@ -79,7 +79,7 @@ function requireAuth(c: CloudContext): AuthUser; ## `AuthUser` {#authuser} -Better Auth user shape re-exported by Void. When the project has an `auth.ts` that exports `defineAuth(...)`, Void generates `.void/auth.d.ts` (listed in `.void/tsconfig.json`) so `AuthUser` also includes fields from Better Auth plugins and `user.additionalFields` in that config — for example `role` from `admin()` or a custom `plan` field. +Better Auth user shape re-exported by Void. It also includes the fields that Better Auth plugins and `user.additionalFields` in `auth.ts` add, for example `role` from `admin()` or a custom `plan` field. Your tsconfig needs the generated Void types for this; see [Type Safety setup](../../guide/type-safety.md#setup). ```ts interface AuthUser { @@ -95,7 +95,7 @@ interface AuthUser { ## `AuthSession` {#authsession} -Better Auth session shape re-exported by Void. With a generated `.void/auth.d.ts`, `AuthSession` picks up session fields contributed by plugins in `auth.ts` (for example `impersonatedBy` from `admin()`). +Better Auth session shape re-exported by Void. It also includes the session fields that Better Auth plugins in `auth.ts` add, for example `impersonatedBy` from `admin()`. ```ts interface AuthSession { @@ -110,6 +110,10 @@ interface AuthSession { } ``` +::: tip Types inside `auth.ts` +`AuthUser` and `AuthSession` are built from `auth.ts`. Inside `auth.ts` and the files it imports, don't use them, or values from `getUser()`, `getSession()` and `requireAuth()`, to type a hook's parameters or returned data. TypeScript reports a circular reference and the extra fields are lost. Let Better Auth infer those types, or use its own `User` and `Session` types. Better Auth callbacks such as `sendResetPassword` pass its base `User`, without plugin fields. +::: + ## `AuthState` {#authstate} Combined authenticated request state: diff --git a/packages/void/src/codegen/auth-types.ts b/packages/void/src/codegen/auth-types.ts index d23131cb9..0fe447a69 100644 --- a/packages/void/src/codegen/auth-types.ts +++ b/packages/void/src/codegen/auth-types.ts @@ -7,12 +7,15 @@ export type AuthTypesResult = { }; /** - * Generate `.void/auth.d.ts` content. Augments `void/auth`'s `AuthUser` and - * `AuthSession` interfaces with Better Auth inference from the app's - * `defineAuth` config (plugins, `user.additionalFields`, etc.). + * Generate `.void/auth.d.ts` content. Fills `void/auth`'s `VoidAuthTypes` + * registry with Better Auth inference from the app's `defineAuth` config + * (plugins, `user.additionalFields`, etc.), which `AuthUser` and `AuthSession` + * read from. * * Uses `typeof import(...)` on the auth config file so TypeScript handles the - * inference — codegen never analyses types itself. + * inference — codegen never analyses types itself. `better-auth` is reached + * through `void/auth`'s `InferAuthSession`, because apps usually do not depend + * on `better-auth` directly and could not resolve it from `.void/`. * * @param authFileAbs Absolute path to the user's auth config file. * @param outDir Absolute path to the project's `.void/` directory. @@ -24,19 +27,17 @@ export function generateAuthTypes(authFileAbs: string, outDir: string): AuthType lines.push('// Auto-generated by void — do not edit'); lines.push('export {}'); lines.push(''); - lines.push('import type { Auth } from "better-auth";'); + lines.push('import type { InferAuthSession } from "void/auth";'); lines.push(''); - lines.push(`type _VoidAuthModule = typeof import("${importPath}");`); - lines.push('type _VoidAuthConfig = _VoidAuthModule["default"];'); lines.push( - 'type _VoidAuthOptions = _VoidAuthConfig extends (...args: never[]) => infer R ? R : _VoidAuthConfig;', + `type _VoidAuthSession = InferAuthSession;`, ); - lines.push('type _InferredUser = Auth<_VoidAuthOptions>["$Infer"]["Session"]["user"];'); - lines.push('type _InferredSession = Auth<_VoidAuthOptions>["$Infer"]["Session"]["session"];'); lines.push(''); lines.push('declare module "void/auth" {'); - lines.push(' interface AuthUser extends _InferredUser {}'); - lines.push(' interface AuthSession extends _InferredSession {}'); + lines.push(' interface VoidAuthTypes {'); + lines.push(' user: _VoidAuthSession["user"];'); + lines.push(' session: _VoidAuthSession["session"];'); + lines.push(' }'); lines.push('}'); lines.push(''); diff --git a/packages/void/src/codegen/prepare.ts b/packages/void/src/codegen/prepare.ts index d312afd85..9d1151209 100644 --- a/packages/void/src/codegen/prepare.ts +++ b/packages/void/src/codegen/prepare.ts @@ -7,8 +7,8 @@ import { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } from '. import { getDatabaseCasing, readConfig } from '../config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; import { loadLocalEnv } from '../shared/env.ts'; +import { generateAuthTypes } from './auth-types.ts'; import { generateDbDts } from './db-types.ts'; -import { generateAuthTypes, removeAuthTypesIfPresent } from './auth-types.ts'; import { findEnvFileForPaths, generateEnvTypes } from './env-types.ts'; import { ensureProjectTsconfig } from './project-tsconfig.ts'; import { generateQueueTypes } from './queue-types.ts'; @@ -58,11 +58,9 @@ export async function prepareProject(root: string): Promise file !== 'auth.d.ts'); + tsconfig.files = existsSync(join(outDir, 'auth.d.ts')) ? [...others, 'auth.d.ts'] : others; + return writeFileIfChanged(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n'); +} diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 9d7ed83fc..f91cd9a24 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -33,12 +33,11 @@ import { lintRoutingPhases, mergeRoutingRules } from './cli/headers.ts'; import { stampClientAssetCachePolicy } from './client-asset-manifest.ts'; import { removeAuthTypesIfPresent, syncAuthTypesFile } from './codegen/auth-types.ts'; import { findEnvFileForPaths } from './codegen/env-types.ts'; -import { ensureProjectTsconfig } from './codegen/project-tsconfig.ts'; +import { syncAuthTypesInProjectTsconfig } from './codegen/project-tsconfig.ts'; import { generateQueueTypes } from './codegen/queue-types.ts'; import { generateRouteTypes } from './codegen/route-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; -import { readConfig, getDatabaseCasing, getDatabaseDialect } from './config.ts'; -import { discoverSchema } from './drizzle/discover.ts'; +import { readConfig, getDatabaseDialect } from './config.ts'; import { collectPages, collectRevalidate, hasRevalidate } from './isr-config.ts'; import { readProjectPaths } from './project-paths.ts'; import { readDeployBuildQueueNames } from './queues/cloudflare-names.ts'; @@ -201,22 +200,6 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug writeFileIfChanged(join(outDir, 'queues.d.ts'), dts); } - function syncProjectTsconfig(): void { - if (!scan) { - return; - } - const analysis = options.getApplicationAnalysis(routeEnvironment); - const config = readConfig(root); - const schema = discoverSchema(root, paths); - const hasRouteTypes = - scan.routes.length > 0 || - (scan.websockets?.length ?? 0) > 0 || - scan.pages.pages.length > 0; - const hasDbRuntime = - schema != null || analysis.authEnabled || getDatabaseCasing(config) !== undefined; - ensureProjectTsconfig(root, hasRouteTypes, hasDbRuntime, schema != null, { paths }); - } - async function rescan(refresh = false) { const analysis = options.getApplicationAnalysis(routeEnvironment, refresh); const nextScan = (await completeApplicationAnalysis(analysis)).scan; @@ -384,7 +367,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug async function writeAuthDatabaseFiles(): Promise { if (!compileOptions.auth) { if (removeAuthTypesIfPresent(outDir)) { - syncProjectTsconfig(); + syncAuthTypesInProjectTsconfig(root); } return; } @@ -411,10 +394,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }), ); - const authConfigPath = compileOptions.auth.authConfigPath; - if (syncAuthTypesFile(authConfigPath, outDir)) { - syncProjectTsconfig(); - } + syncAuthTypesFile(compileOptions.auth.authConfigPath, outDir); + syncAuthTypesInProjectTsconfig(root); } let resolvedConfig: import('vite').ResolvedConfig | null = null; diff --git a/packages/void/src/runtime/auth.ts b/packages/void/src/runtime/auth.ts index 98fc01cbb..e34cbbb69 100644 --- a/packages/void/src/runtime/auth.ts +++ b/packages/void/src/runtime/auth.ts @@ -1,10 +1,37 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -import type { BetterAuthOptions, Session, User } from 'better-auth'; +import type { Auth, BetterAuthOptions, Session, User } from 'better-auth'; import type { Context, Env } from 'hono'; import { HTTPException } from 'hono/http-exception'; -export interface AuthUser extends User {} -export interface AuthSession extends Session {} +/** + * Filled in by the generated `.void/auth.d.ts` with the user and session types + * Better Auth infers from the app's `auth.ts`. Empty when the app has no + * `auth.ts`, so `AuthUser`/`AuthSession` fall back to Better Auth's base shapes. + * + * `AuthUser`/`AuthSession` stay type aliases (not merged interfaces) so they + * remain assignable to `Record`, and a union config (a function + * form that returns different shapes per branch) stays a valid union type. + */ +export interface VoidAuthTypes {} + +/** + * Better Auth's inferred `{ user, session }` for an `auth.ts` default export. + * Used by the generated `.void/auth.d.ts`; it lives here so `better-auth` + * resolves from Void's own dependencies, not the app's. + * + * An `any` default export (an `auth.js` without `allowJs`, or an import that + * does not resolve) keeps Better Auth's base shapes instead of a user type that + * accepts any key. The tuple check also catches the error `any` of an untyped + * import. + */ +export type InferAuthSession = [unknown] extends [T] + ? { user: User; session: Session } + : Auth< + Extract) => infer R ? R : T, BetterAuthOptions> + >['$Infer']['Session']; + +export type AuthUser = VoidAuthTypes extends { user: infer U } ? U : User; +export type AuthSession = VoidAuthTypes extends { session: infer S } ? S : Session; export type AuthState = { session: AuthSession; diff --git a/packages/void/test/integration/auth-types-typecheck.test.ts b/packages/void/test/integration/auth-types-typecheck.test.ts index a262f3bd9..f753529e9 100644 --- a/packages/void/test/integration/auth-types-typecheck.test.ts +++ b/packages/void/test/integration/auth-types-typecheck.test.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vite-plus/test'; import { generateAuthTypes } from '../../src/codegen/auth-types.ts'; @@ -13,15 +13,58 @@ afterEach(() => { } }); +/** + * Typecheck `auth.ts` plus `handler.ts` in a kitchen-sink app that depends on + * `void` only, the way pnpm lays it out: the app has no `better-auth` of its own. + * An `auth.js` config is left out of `files`, as in a TS app without `allowJs`. + */ +function typecheck( + authSource: string, + handlerSource: string, + paths: Record>, + authFileName = 'auth.ts', +) { + const root = mkdtempSync(join(workspaceRoot, 'playground/kitchen-sink/.tmp-auth-types-')); + roots.push(root); + const authFile = join(root, authFileName); + const outDir = join(root, '.void'); + mkdirSync(outDir); + writeFileSync(authFile, authSource); + writeFileSync(join(outDir, 'auth.d.ts'), generateAuthTypes(authFile, outDir).dts); + writeFileSync(join(root, 'handler.ts'), handlerSource); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + skipLibCheck: true, + noEmit: true, + paths, + }, + files: [ + ...(authFileName.endsWith('.js') ? [] : [authFileName]), + '.void/auth.d.ts', + 'handler.ts', + ], + }), + ); + return spawnSync( + process.execPath, + [join(workspaceRoot, 'node_modules/typescript/lib/tsc.js'), '--project', root], + { encoding: 'utf8' }, + ); +} + describe('generated auth types in a consumer project', () => { it('types plugin fields and additionalFields on user, session, and context', () => { - const root = mkdtempSync(join(workspaceRoot, 'playground/kitchen-sink/.tmp-auth-types-')); - roots.push(root); - const authFile = join(root, 'auth.ts'); - const outDir = join(root, '.void'); - mkdirSync(outDir); - writeFileSync( - authFile, + // Only auth.ts's own plugin import needs a mapping; the generated file + // reaches better-auth through void/auth. + const betterAuthPkg = join(workspaceRoot, 'packages/void/node_modules/better-auth'); + const adminTypes = join(betterAuthPkg, 'dist/plugins/admin/index.d.mts'); + const result = typecheck( 'import { admin } from "better-auth/plugins/admin";\n' + 'import { defineAuth } from "void/auth";\n' + 'export default defineAuth(({ defaults }) => ({\n' + @@ -29,48 +72,62 @@ describe('generated auth types in a consumer project', () => { ' plugins: [admin()],\n' + ' user: { additionalFields: { plan: { type: "string", required: false } } },\n' + '}));\n', - ); - writeFileSync(join(outDir, 'auth.d.ts'), generateAuthTypes(authFile, outDir).dts); - writeFileSync( - join(root, 'handler.ts'), 'import { defineHandler } from "void";\n' + 'import { getUser, getSession, requireAuth } from "void/auth";\n' + + 'import { logger } from "void/log";\n' + 'export const loader = defineHandler((c) => {\n' + - ' const role = c.get("user")?.role;\n' + - ' const plan = getUser()?.plan;\n' + - ' const impersonatedBy = c.get("session")?.impersonatedBy;\n' + - ' const sessionImpersonatedBy = getSession()?.session.impersonatedBy;\n' + - ' const requiredRole = requireAuth(c).role;\n' + - ' return { role, plan, impersonatedBy, sessionImpersonatedBy, requiredRole };\n' + + ' const role: string | null | undefined = c.get("user")?.role;\n' + + ' const plan: string | null | undefined = getUser()?.plan;\n' + + ' const impersonatedBy: string | null | undefined = c.get("session")?.impersonatedBy;\n' + + ' const sessionImpersonatedBy: string | null | undefined =\n' + + ' getSession()?.session.impersonatedBy;\n' + + ' const user = requireAuth(c);\n' + + ' const requiredRole: string | null | undefined = user.role;\n' + + ' const fields: Record = user;\n' + + ' logger.info("signed in", user);\n' + + ' // @ts-expect-error not a field of the inferred user\n' + + ' getUser()?.notAField;\n' + + ' return { role, plan, impersonatedBy, sessionImpersonatedBy, requiredRole, fields };\n' + '});\n', + { 'better-auth/plugins/admin': [adminTypes] }, ); - const betterAuthPkg = join(workspaceRoot, 'packages/void/node_modules/better-auth'); - const betterAuthRel = relative(root, betterAuthPkg); - writeFileSync( - join(root, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - target: 'ES2022', - module: 'ESNext', - moduleResolution: 'bundler', - strict: true, - skipLibCheck: true, - noEmit: true, - paths: { - 'better-auth': [betterAuthRel], - 'better-auth/plugins/admin': [ - `${betterAuthRel}/dist/plugins/admin/index.d.mts`, - ], - }, - }, - files: ['auth.ts', '.void/auth.d.ts', 'handler.ts'], - }), + expect(result.status, result.stdout + result.stderr).toBe(0); + }); + + it('keeps fields every branch shares when auth.ts returns different shapes', () => { + const result = typecheck( + 'import { defineAuth } from "void/auth";\n' + + 'const plan = { type: "string", required: true } as const;\n' + + 'const trialDays = { type: "number", required: true } as const;\n' + + 'const seats = { type: "number", required: true } as const;\n' + + 'export default defineAuth(({ defaults, env }) => {\n' + + ' const trial = { ...defaults, user: { additionalFields: { plan, trialDays } } };\n' + + ' const paid = { ...defaults, user: { additionalFields: { plan, seats } } };\n' + + ' return env.TRIAL ? trial : paid;\n' + + '});\n', + 'import { getUser } from "void/auth";\n' + + 'export const plan: string = getUser()!.plan;\n' + + '// @ts-expect-error only one branch adds trialDays\n' + + 'export const trialDays: number = getUser()!.trialDays;\n', + {}, ); + expect(result.status, result.stdout + result.stderr).toBe(0); + }); - const result = spawnSync( - process.execPath, - [join(workspaceRoot, 'node_modules/typescript/lib/tsc.js'), '--project', root], - { encoding: 'utf8' }, + it('keeps Better Auth base types when a JS auth config cannot be typed', () => { + const result = typecheck( + 'import { defineAuth } from "void/auth";\n' + + 'export default defineAuth({\n' + + ' user: { additionalFields: { plan: { type: "string", required: true } } },\n' + + '});\n', + 'import { getUser, type AuthUser } from "void/auth";\n' + + 'export const email: string = getUser()!.email;\n' + + '// @ts-expect-error not a key of the base user\n' + + 'export const key: keyof AuthUser = "notAField";\n' + + '// @ts-expect-error not a field of the base user\n' + + 'export const plan: number = getUser()!.notAField;\n', + {}, + 'auth.js', ); expect(result.status, result.stdout + result.stderr).toBe(0); }); diff --git a/packages/void/test/unit/auth-types-codegen.test.ts b/packages/void/test/unit/auth-types-codegen.test.ts index b84c5b4c0..da8a2749d 100644 --- a/packages/void/test/unit/auth-types-codegen.test.ts +++ b/packages/void/test/unit/auth-types-codegen.test.ts @@ -41,18 +41,23 @@ describe('syncAuthTypesFile', () => { }); describe('generateAuthTypes', () => { - it('emits a module that augments void/auth AuthUser and AuthSession', () => { + it('emits a module that fills the void/auth VoidAuthTypes registry', () => { const { root, outDir, cleanup } = withTempProject((r) => { writeFileSync(join(r, 'auth.ts'), 'export default {};'); }); try { const { dts } = generateAuthTypes(join(root, 'auth.ts'), outDir); expect(dts).toContain('export {}'); + // better-auth is reached through void/auth: apps usually cannot resolve it themselves. + expect(dts).toContain('import type { InferAuthSession } from "void/auth";'); + expect(dts).not.toContain('from "better-auth"'); + expect(dts).toContain( + 'type _VoidAuthSession = InferAuthSession;', + ); expect(dts).toContain('declare module "void/auth"'); - expect(dts).toContain('interface AuthUser extends _InferredUser'); - expect(dts).toContain('interface AuthSession extends _InferredSession'); - expect(dts).toContain('typeof import("../auth.ts")'); - expect(dts).toContain('Auth<_VoidAuthOptions>["$Infer"]["Session"]["user"]'); + expect(dts).toContain('interface VoidAuthTypes {'); + expect(dts).toContain('user: _VoidAuthSession["user"];'); + expect(dts).toContain('session: _VoidAuthSession["session"];'); } finally { cleanup(); } diff --git a/packages/void/test/unit/auth-types-prepare.test.ts b/packages/void/test/unit/auth-types-prepare.test.ts index e4653c91b..c80cfb238 100644 --- a/packages/void/test/unit/auth-types-prepare.test.ts +++ b/packages/void/test/unit/auth-types-prepare.test.ts @@ -1,26 +1,25 @@ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, relative } from 'node:path'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vite-plus/test'; -import { createProjectTsconfig } from '../../src/codegen/project-tsconfig.ts'; -const workspaceRoot = join(import.meta.dirname, '..', '..', '..', '..'); const roots: Array = []; -function runPrepare(root: string): void { - execFileSync( +function runPrepare(root: string): { written: Array; removed: Array } { + const stdout = execFileSync( process.execPath, [ '--input-type=module', '-e', - 'const { prepareProject } = await import(process.argv[1]); await prepareProject(process.argv[2]);', + 'const { prepareProject } = await import(process.argv[1]); console.log(JSON.stringify(await prepareProject(process.argv[2])));', fileURLToPath(new URL('../../src/codegen/prepare.ts', import.meta.url)), root, ], - { stdio: 'pipe' }, + { encoding: 'utf8', stdio: 'pipe' }, ); + return JSON.parse(stdout.trim().split('\n').at(-1)!); } afterEach(() => { @@ -34,22 +33,12 @@ describe('auth types prepare lifecycle', () => { const root = mkdtempSync(join(tmpdir(), 'void-auth-prepare-')); roots.push(root); mkdirSync(join(root, '.void'), { recursive: true }); - const voidAuthTypes = relative(root, join(workspaceRoot, 'packages/void/src/runtime/auth.ts')); - writeFileSync( - join(root, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { paths: { 'void/auth': [voidAuthTypes] } }, - }), - ); writeFileSync( join(root, 'auth.ts'), 'import type { VoidAuthConfig } from "void/auth";\nexport default {} satisfies VoidAuthConfig;\n', ); - expect(createProjectTsconfig(root).files).not.toContain('auth.d.ts'); - - runPrepare(root); - + expect(runPrepare(root).written).toContain('.void/auth.d.ts'); expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(true); const withAuth = JSON.parse(readFileSync(join(root, '.void', 'tsconfig.json'), 'utf8')) as { files: Array; @@ -57,8 +46,7 @@ describe('auth types prepare lifecycle', () => { expect(withAuth.files).toContain('auth.d.ts'); rmSync(join(root, 'auth.ts')); - runPrepare(root); - + expect(runPrepare(root).removed).toContain('.void/auth.d.ts'); expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(false); const withoutAuth = JSON.parse(readFileSync(join(root, '.void', 'tsconfig.json'), 'utf8')) as { files: Array; diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 76359de1d..5bb76b13f 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -22,6 +22,7 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; import { GENERATED_ASSET_HEADERS_MARKER } from '../../src/asset-worker-bypass.ts'; import * as authConfig from '../../src/auth/config.ts'; +import { ensureProjectTsconfig } from '../../src/codegen/project-tsconfig.ts'; import { readConfig } from '../../src/config.ts'; import { routingPlugin } from '../../src/plugin-routing.ts'; import { readProjectPaths } from '../../src/project-paths.ts'; @@ -212,6 +213,42 @@ describe('routingPlugin', () => { expect(schema).toContain('pronouns'); }); + it('writes and removes auth.d.ts and lists it in the existing tsconfig only', async () => { + const root = createTempRoot(); + writeFileSync(join(root, 'routes', 'health.ts'), 'export const GET = () => new Response();'); + writeFileSync( + join(root, 'auth.ts'), + "import type { VoidAuthConfig } from 'void/auth';\nexport default {} satisfies VoidAuthConfig;\n", + ); + const tsconfigPath = join(root, '.void', 'tsconfig.json'); + const readTsconfig = () => + JSON.parse(readFileSync(tsconfigPath, 'utf8')) as { + files: Array; + compilerOptions?: { paths?: Record> }; + }; + + // The Drizzle plugin wrote this tsconfig at startup, before auth.ts existed: no db.d.ts. + ensureProjectTsconfig(root, true, false); + const withAuth = createRoutingPlugin(root, { isAuthEnabled: () => true }); + resolveConfig(withAuth, 'serve'); + await runBuildStart(withAuth); + + expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(true); + expect(readTsconfig().files).toEqual(['routes.d.ts', 'auth.d.ts']); + + // A pg app keeps db.d.ts and its void/db path after auth is turned off. + ensureProjectTsconfig(root, true, true); + expect(readTsconfig().files).toEqual(['routes.d.ts', 'db.d.ts', 'auth.d.ts']); + rmSync(join(root, 'auth.ts')); + const withoutAuth = createRoutingPlugin(root, { isAuthEnabled: () => false }); + resolveConfig(withoutAuth, 'build'); + await runBuildStart(withoutAuth); + + expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(false); + expect(readTsconfig().files).toEqual(['routes.d.ts', 'db.d.ts']); + expect(readTsconfig().compilerOptions?.paths?.['void/db']).toEqual(['./db.d.ts']); + }); + it('bakes explicit page opt-outs into the native ISR policy', async () => { const root = createTempRoot(); mkdirSync(join(root, 'pages'), { recursive: true });