diff --git a/.changeset/tidy-otters-infer.md b/.changeset/tidy-otters-infer.md new file mode 100644 index 00000000..d02a6100 --- /dev/null +++ b/.changeset/tidy-otters-infer.md @@ -0,0 +1,18 @@ +--- +'@opensaas/stack-auth': minor +--- + +`buildBetterAuthOptions()` and `createAuth()` now accept an optional third argument — your app's `betterAuthPlugins` array, the same array passed to `authPlugin({ betterAuthPlugins })` — so the returned options/`Auth` type carries the literal plugin tuple instead of the widened `BetterAuthOptions`/`Auth`. Without this, `betterAuth()` constructed from the widened return loses plugin-derived `auth.api.*` endpoints (e.g. `emailOTP()`'s `signInEmailOTP`) and a `customSession()` plugin's replaced session shape. + +```typescript +export const appBetterAuthPlugins = [emailOTP({ sendVerificationOTP })] // same array passed to authPlugin({ betterAuthPlugins }) + +export const auth = betterAuth({ + ...(await buildBetterAuthOptions(config, rawOpensaasContext, appBetterAuthPlugins)), +}) +// auth.api.signInEmailOTP is now typed, and auth.api.getSession() returns your customSession() shape. +``` + +The supplied tuple is for typing only — the plugin array used at runtime is always the one resolved from `authPlugin({ betterAuthPlugins })`. Passing a tuple that isn't the same plugin instances in the same order throws, naming the mismatch, so the two can't silently drift apart. Calling either function with no third argument is unchanged — same widened return type, same runtime options, fully backwards compatible. + +Also, `AuthConfig`/`NormalizedAuthConfig`'s `betterAuthPlugins` field is now typed as better-auth's own `BetterAuthPlugin[]` instead of `any[]`. diff --git a/docs/content/reference/auth.md b/docs/content/reference/auth.md index bf9b5afe..7444ebfc 100644 --- a/docs/content/reference/auth.md +++ b/docs/content/reference/auth.md @@ -398,6 +398,71 @@ an explicit, reviewable diff. It also gives an incremental migration path onto `createAuth()`: adopt the builder first, then move options into [`betterAuthOptions`](#betterauthoptions) as the stack grows knobs for them. +### Typed `auth.api.*` reads: pass your plugin tuple + +Called with just `(config, context)`, both `createAuth()` and +`buildBetterAuthOptions()` return the **widened** `BetterAuthOptions` / +`Auth` types. better-auth infers plugin endpoints and a +`customSession()`'s replaced session shape from the _literal_ type of the +options object, so constructing from a widened type erases them — a plugin +like `emailOTP()` loses `auth.api.signInEmailOTP`, and `auth.api.getSession()` +falls back to better-auth's default `{ user, session }` instead of your +`customSession()` callback's return type. + +If your app reads `auth.api.*` in typed code and uses either of those, +**pass your `betterAuthPlugins` array as a third argument** — the exact same +array already passed to `authPlugin({ betterAuthPlugins })` — to either +function: + +```typescript +// auth-plugins.ts +import { emailOTP } from 'better-auth/plugins' + +export const appBetterAuthPlugins = [emailOTP({ sendVerificationOTP })] +``` + +```typescript +// opensaas.config.ts +import { authPlugin } from '@opensaas/stack-auth' +import { appBetterAuthPlugins } from './auth-plugins' + +export default config({ + plugins: [authPlugin({ betterAuthPlugins: appBetterAuthPlugins })], + // ... +}) +``` + +```typescript +// lib/auth.ts +import { betterAuth } from 'better-auth' +import { buildBetterAuthOptions } from '@opensaas/stack-auth/server' +import config from '../opensaas.config' +import { rawOpensaasContext } from '@/.opensaas/context' +import { appBetterAuthPlugins } from '../auth-plugins' + +export const auth = betterAuth({ + ...(await buildBetterAuthOptions(config, rawOpensaasContext, appBetterAuthPlugins)), + databaseHooks: { user: { create: { after: syncDomainUser } } }, +}) +// auth.api.signInEmailOTP is now typed, and auth.api.getSession() returns +// your customSession() shape if you have one. +``` + +The same third argument works on `createAuth()` — `createAuth(config, rawOpensaasContext, appBetterAuthPlugins)`. +Either way, the supplied array is for typing only: the plugin array actually +used at runtime is always the one resolved from `authPlugin({ betterAuthPlugins })`, +with exactly one `nextCookies()` appended last. Passing an array that isn't +the same plugin instances in the same order throws, naming the mismatch, so +the two can't silently drift apart. + +**Which entry point to reach for:** `createAuth()`'s lazy `Proxy` does not +behave identically to a real `Auth` instance for every property — every +access, including a non-function property, is surfaced through an `async` +wrapper (so `auth.options`, for example, reads back as a `Promise` rather than +the plain object a real instance returns synchronously). If your app reads +`auth.api.*` in typed code, reach for `buildBetterAuthOptions()` plus +`betterAuth()` — it constructs a real instance and does not have this gap. + ## Client Setup Create a client for authentication in your components: diff --git a/eslint.config.js b/eslint.config.js index 66682ec5..febf500b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -47,6 +47,11 @@ export default [ ...tseslint.configs.recommended.rules, ...react.configs.recommended.rules, ...reactHooks.configs.recommended.rules, + // Base `no-redeclare` doesn't understand TypeScript function overload + // signatures and flags each one as a duplicate declaration; + // `@typescript-eslint/no-redeclare` is overload-aware. + 'no-redeclare': 'off', + '@typescript-eslint/no-redeclare': 'error', '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': [ 'warn', diff --git a/packages/auth/CLAUDE.md b/packages/auth/CLAUDE.md index 8d4629a2..8671ee4c 100644 --- a/packages/auth/CLAUDE.md +++ b/packages/auth/CLAUDE.md @@ -28,8 +28,8 @@ Auto-generated lists: ### Server (`src/server/index.ts`) -- `createAuth(config, rawContext?)` - Creates Better-auth instance with MCP plugin support -- `buildBetterAuthOptions(config, rawContext?)` - Returns the same `BetterAuthOptions` `createAuth()` builds, without constructing an instance — for apps that need to hand-wire their own `betterAuth()` +- `createAuth(config, rawContext?, betterAuthPlugins?)` - Creates Better-auth instance with MCP plugin support +- `buildBetterAuthOptions(config, rawContext?, betterAuthPlugins?)` - Returns the same `BetterAuthOptions` `createAuth()` builds, without constructing an instance — for apps that need to hand-wire their own `betterAuth()`. The optional third argument (the app's own `betterAuthPlugins` array) makes the return type carry that literal plugin tuple instead of the widened array type — see "Typed `auth.api.*` reads" below. - Returns `{ handler, signIn, signOut, ... }` - Better-auth methods ### Client (`src/client/index.ts`) @@ -391,6 +391,43 @@ can't be synchronous. It gives an incremental path onto `createAuth()`: adopt the builder first, then fold options into `betterAuthOptions` above as the stack grows first-class config for them. +**Typed `auth.api.*` reads.** Called with just `(config, context)`, both +`buildBetterAuthOptions()` and `createAuth()` return the widened +`BetterAuthOptions` / `Auth` — better-auth infers plugin +endpoints and a `customSession()`'s replaced session shape from the _literal_ +options type, so the widened form erases them (an `emailOTP()` plugin loses +`auth.api.signInEmailOTP`; `auth.api.getSession()` falls back to `{ user, +session }` instead of a `customSession()` shape). Both functions take the +app's `betterAuthPlugins` array — the exact same array passed to +`authPlugin({ betterAuthPlugins })` — as an optional third argument, and their +return type then carries that literal tuple (plus the `nextCookies()` the +stack always appends last) instead of the widened array type: + +```typescript +export const appBetterAuthPlugins = [emailOTP({ sendVerificationOTP })] // same array passed to authPlugin({ betterAuthPlugins }) + +export const auth = betterAuth({ + ...(await buildBetterAuthOptions(config, rawOpensaasContext, appBetterAuthPlugins)), +}) +// auth.api.signInEmailOTP is now typed. +``` + +The supplied tuple is for typing only — the plugin array used at runtime is +always the one resolved from `authPlugin({ betterAuthPlugins })` — so both +functions verify the supplied tuple is the same plugin instances in the same +order as the resolved array, throwing a prefixed error naming the mismatch if +not (`assertPluginTupleMatchesResolved` in `src/server/index.ts`). This is +what closes the drift hole a hand-rolled re-pass of the plugin array would +otherwise open. + +`createAuth()`'s lazy `Proxy` does not behave identically to a real `Auth` +instance for every property regardless of which form you use — every access, +including a non-function property, is surfaced through an `async` wrapper (so +`auth.options` reads back as a `Promise`, not the plain object a real +instance returns synchronously). Reach for `buildBetterAuthOptions()` plus +`betterAuth()` instead when the app reads `auth.api.*` in typed code — it +constructs a real instance and does not have this gap. + ## Integration Points ### With @opensaas/stack-core diff --git a/packages/auth/src/config/plugin.ts b/packages/auth/src/config/plugin.ts index 40a2d8bc..fe4b7927 100644 --- a/packages/auth/src/config/plugin.ts +++ b/packages/auth/src/config/plugin.ts @@ -102,7 +102,7 @@ export function authPlugin(config: AuthConfig): Plugin { // `oauth_application`, `passkey`) still register as new lists via // `addList`, same as before. for (const plugin of normalized.betterAuthPlugins) { - if (plugin && typeof plugin === 'object' && 'schema' in plugin) { + if (plugin && typeof plugin === 'object' && plugin.schema) { // Plugin has schema property - convert to OpenSaaS lists const pluginSchema = plugin.schema const pluginLists = convertBetterAuthSchema(pluginSchema, baseModelKeys) diff --git a/packages/auth/src/config/types.ts b/packages/auth/src/config/types.ts index 21719476..c8602c20 100644 --- a/packages/auth/src/config/types.ts +++ b/packages/auth/src/config/types.ts @@ -1,5 +1,5 @@ import type { ListConfig } from '@opensaas/stack-core' -import type { BetterAuthOptions, User } from 'better-auth' +import type { BetterAuthOptions, BetterAuthPlugin, User } from 'better-auth' import type { ExtendUserListConfig } from '../lists/index.js' /** @@ -394,8 +394,7 @@ export type AuthConfig = { * ] * ``` */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth plugin types are not exposed, must use any - betterAuthPlugins?: any[] + betterAuthPlugins?: BetterAuthPlugin[] /** * Rate limiting configuration @@ -528,8 +527,7 @@ export type NormalizedAuthConfig = Required< * default (used to wire the datasource `schemas` array during generation). */ schema?: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth plugin types are not exposed, must use any - betterAuthPlugins: any[] + betterAuthPlugins: BetterAuthPlugin[] rateLimit?: { enabled: boolean window?: number diff --git a/packages/auth/src/server/build-better-auth-options.test.ts b/packages/auth/src/server/build-better-auth-options.test.ts new file mode 100644 index 00000000..3bb3e530 --- /dev/null +++ b/packages/auth/src/server/build-better-auth-options.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expectTypeOf } from 'vitest' +import { betterAuth } from 'better-auth' +import type { BetterAuthOptions } from 'better-auth' +import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core' +import { emailOTP, customSession } from 'better-auth/plugins' +import { buildBetterAuthOptions } from './index.js' + +// The literal shape a `customSession()` callback replaces the session with — +// deliberately unlike better-auth's default `{ user, session }`, to prove the +// builder's return type carries a custom shape through to `api.getSession()`. +type AppSession = { + data: { allowAdminUI: boolean; subjectId: string } +} + +type TestPlugins = [ReturnType, ReturnType>] + +// `ReturnType` on the overloaded export itself +// resolves against its last (generic) signature, not the call-site-selected +// one — wrapping each call shape in its own ordinary function and reading +// `ReturnType` instead forces TS to resolve overloads exactly +// as a real call site would. +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below +function callWithNoPlugins( + config: OpenSaasConfig | Promise, + context: AccessContext | Promise, +) { + return buildBetterAuthOptions(config, context) +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below +function callWithPlugins( + config: OpenSaasConfig | Promise, + context: AccessContext | Promise, + plugins: TestPlugins, +) { + return buildBetterAuthOptions(config, context, plugins) +} + +type NoArgResult = Awaited> +type BuiltOptionsWithPlugins = Awaited> +type ConstructedAuth = ReturnType> + +describe('buildBetterAuthOptions plugin-tuple typing', () => { + it('back-compat: the no-argument call still returns the widened BetterAuthOptions', () => { + expectTypeOf().toEqualTypeOf() + }) + + it('preserves plugin-derived auth.api.* endpoints and a customSession shape', () => { + // emailOTP() endpoints exist on the constructed Auth's `api` — erased entirely + // when constructed from the widened `BetterAuthOptions` (see #876). + expectTypeOf().not.toBeNever() + expectTypeOf().not.toBeNever() + expectTypeOf().not.toBeNever() + + // customSession()'s replaced shape, not better-auth's default { user, session }. + type GetSessionReturn = Awaited> + expectTypeOf().toEqualTypeOf() + }) +}) diff --git a/packages/auth/src/server/index.ts b/packages/auth/src/server/index.ts index 59228d94..0c0770f5 100644 --- a/packages/auth/src/server/index.ts +++ b/packages/auth/src/server/index.ts @@ -1,11 +1,58 @@ import { betterAuth } from 'better-auth' import { prismaAdapter } from 'better-auth/adapters/prisma' import { nextCookies } from 'better-auth/next-js' -import type { BetterAuthOptions } from 'better-auth' +import type { Auth, BetterAuthOptions, BetterAuthPlugin } from 'better-auth' import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core' import type { DatabaseConfig } from '@opensaas/stack-core/internal' import type { NormalizedAuthConfig, NormalizedAuthModelConfig } from '../config/types.js' +/** + * The `BetterAuthOptions` shape produced when an app's own plugin tuple is + * passed to `buildBetterAuthOptions()`/`createAuth()` — the tuple plus the + * `nextCookies()` plugin the stack always appends last. Carrying the literal + * tuple type (rather than the widened `BetterAuthPlugin[]`) is what lets + * `betterAuth()` re-infer plugin endpoints (e.g. `emailOTP()`'s + * `api.signInEmailOTP`) and a `customSession()` plugin's replaced session + * shape from the resulting options object. + */ +type ResolvedBetterAuthOptions = Omit< + BetterAuthOptions, + 'plugins' +> & { + plugins: [...TPlugins, ReturnType] +} + +/** + * Guard against the supplied plugin tuple silently drifting from the plugin + * array actually resolved from `authPlugin({ betterAuthPlugins })` — the + * supplied tuple exists for typing only, so if it isn't the exact same + * instances in the exact same order, the type it produces would be a lie + * about what `betterAuth()` is actually constructed with. + */ +function assertPluginTupleMatchesResolved( + supplied: readonly BetterAuthPlugin[], + resolved: readonly BetterAuthPlugin[], +): void { + if (supplied.length !== resolved.length) { + throw new Error( + '[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' + + `has ${supplied.length} plugin(s), but the plugin array resolved from \`authPlugin({ ` + + `betterAuthPlugins })\` has ${resolved.length}. Pass the exact same array (without ` + + '`nextCookies()` — the stack appends that itself).', + ) + } + + const mismatchIndex = supplied.findIndex((plugin, index) => plugin !== resolved[index]) + if (mismatchIndex !== -1) { + throw new Error( + '[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' + + `does not match the plugin array resolved from \`authPlugin({ betterAuthPlugins })\` at index ` + + `${mismatchIndex} (got plugin "${supplied[mismatchIndex]?.id}", expected the same instance as ` + + `"${resolved[mismatchIndex]?.id}"). Pass the exact same array — same instances, same order.`, + ) + } +} + /** * Get better-auth database configuration from OpenSaas config */ @@ -125,21 +172,55 @@ function mergeBetterAuthOptions( * authoritative for everything it models; the app's additions on top become * an explicit, reviewable diff instead of a parallel, hand-duplicated config. * - * @example + * Called with just `(config, context)`, the return type is the widened + * `BetterAuthOptions` — `betterAuth()` infers its plugin/session types from + * the *literal* type of the options object, so constructing from this + * widened return erases plugin endpoints (e.g. `emailOTP()`'s + * `api.signInEmailOTP`) and a `customSession()` plugin's replaced session + * shape. **If your app reads `auth.api.*` in typed code and uses either of + * those, pass its plugin tuple as the third argument** — the exact same + * array already passed to `authPlugin({ betterAuthPlugins })` — so the + * return type carries the literal tuple instead: + * * ```typescript * import { betterAuth } from 'better-auth' + * import { emailOTP } from 'better-auth/plugins' * import { buildBetterAuthOptions } from '@opensaas/stack-auth/server' * + * export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins }) + * * export const auth = betterAuth({ - * ...(await buildBetterAuthOptions(config, context)), + * ...(await buildBetterAuthOptions(config, context, appBetterAuthPlugins)), * databaseHooks: { user: { create: { after: syncDomainUser } } }, * }) + * // auth.api.signInEmailOTP / auth.api.getSession()'s customSession shape are now typed. * ``` + * + * The supplied tuple is for typing only — the array actually used at runtime + * is always the one resolved from `authPlugin({ betterAuthPlugins })`, with + * exactly one `nextCookies()` appended last. Passing a tuple that isn't the + * same plugin instances in the same order throws, so the two can't silently + * drift apart. + * + * Note `createAuth()`'s lazy Proxy does not behave identically to a real + * `Auth` instance for every property (see its own doc comment) — reach for + * this builder plus `betterAuth()` instead when the app reads `auth.api.*` + * in typed code. */ export async function buildBetterAuthOptions( opensaasConfig: OpenSaasConfig | Promise, context: AccessContext | Promise, -): Promise { +): Promise +export async function buildBetterAuthOptions( + opensaasConfig: OpenSaasConfig | Promise, + context: AccessContext | Promise, + plugins: TPlugins, +): Promise> +export async function buildBetterAuthOptions( + opensaasConfig: OpenSaasConfig | Promise, + context: AccessContext | Promise, + plugins?: TPlugins, +): Promise> { const resolvedConfig = await Promise.resolve(opensaasConfig) const resolvedContext = await Promise.resolve(context) @@ -179,6 +260,11 @@ export async function buildBetterAuthOptions( assertNoUnsupportedPassthroughKeys(authConfig.betterAuthOptions as Record) + const resolvedPlugins = authConfig.betterAuthPlugins || [] + if (plugins) { + assertPluginTupleMatchesResolved(plugins, resolvedPlugins) + } + // Build better-auth configuration const betterAuthConfig: BetterAuthOptions = { database: getDatabaseConfig(resolvedConfig.db, resolvedContext), @@ -259,47 +345,81 @@ export async function buildBetterAuthOptions( // cookie store. This is what makes the server-action auth forms (which // call auth.api.signInEmail/signUpEmail/etc. server-side) actually // persist a session. It must be the final plugin in the array. - plugins: [...(authConfig.betterAuthPlugins || []), nextCookies()], + plugins: [...resolvedPlugins, nextCookies()], } return mergeBetterAuthOptions( betterAuthConfig as unknown as Record, authConfig.betterAuthOptions as Record, - ) as BetterAuthOptions + ) as BetterAuthOptions | ResolvedBetterAuthOptions } /** * Create a better-auth instance from OpenSaas config * This should be called once at app startup * - * @example + * Returns a lazy `Proxy` (see the caveat below), typed as `Auth` + * when called with just `(config, context)` — the widened type, same erasure + * caveat as {@link buildBetterAuthOptions}'s no-argument form. **If your app + * reads `auth.api.*` in typed code and relies on a plugin's endpoints (e.g. + * `emailOTP()`) or a `customSession()`'s replaced session shape, pass its + * plugin tuple as the third argument** — the exact same array already passed + * to `authPlugin({ betterAuthPlugins })` — so the declared type carries the + * literal tuple instead: + * * ```typescript * // lib/auth.ts * import { createAuth } from '@opensaas/stack-auth/server' * import config from '../opensaas.config' * import { rawOpensaasContext } from '@/.opensaas/context' * - * export const auth = createAuth(config, rawOpensaasContext) + * export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins }) + * + * export const auth = createAuth(config, rawOpensaasContext, appBetterAuthPlugins) * ``` + * + * As with the builder, the supplied tuple is for typing only, and a tuple + * that isn't the same plugin instances in the same order throws. + * + * **Proxy caveat:** the lazy `Proxy` this returns does not behave identically + * to a real `Auth` instance for every property — every access, including a + * non-function property, is surfaced through an `async` wrapper (so e.g. + * `auth.options` reads back as a `Promise`, not the plain object a real + * instance would return synchronously). The declared type does not model + * this difference; where it matters, reach for {@link buildBetterAuthOptions} + * plus `betterAuth()` instead, which constructs a real instance. */ export function createAuth( opensaasConfig: OpenSaasConfig | Promise, context: AccessContext | Promise, -) { +): Auth +export function createAuth( + opensaasConfig: OpenSaasConfig | Promise, + context: AccessContext | Promise, + plugins: TPlugins, +): Auth> +export function createAuth( + opensaasConfig: OpenSaasConfig | Promise, + context: AccessContext | Promise, + plugins?: TPlugins, +): Auth | Auth> { // Resolve config and context asynchronously const configPromise = Promise.resolve(opensaasConfig) const contextPromise = Promise.resolve(context) // Create auth instance lazily when needed - let authInstance: ReturnType | null = null - let authPromise: Promise> | null = null + type AuthInstance = Auth | Auth> + let authInstance: AuthInstance | null = null + let authPromise: Promise | null = null async function getAuthInstance() { if (authInstance) return authInstance if (!authPromise) { authPromise = (async () => { - const betterAuthConfig = await buildBetterAuthOptions(configPromise, contextPromise) + const betterAuthConfig = plugins + ? await buildBetterAuthOptions(configPromise, contextPromise, plugins) + : await buildBetterAuthOptions(configPromise, contextPromise) authInstance = betterAuth(betterAuthConfig) return authInstance })() @@ -309,7 +429,7 @@ export function createAuth( } // Return a proxy that lazily initializes the auth instance - return new Proxy({} as ReturnType, { + return new Proxy({} as AuthInstance, { get(_, prop) { if (prop === 'then') { // Support await on the proxy itself diff --git a/packages/auth/src/server/schema-converter.ts b/packages/auth/src/server/schema-converter.ts index 1cd1a9ad..67a1d264 100644 --- a/packages/auth/src/server/schema-converter.ts +++ b/packages/auth/src/server/schema-converter.ts @@ -12,13 +12,13 @@ import type { ListConfig, FieldConfig } from '@opensaas/stack-core' * Inferred from better-auth internal types */ type BetterAuthFieldAttribute = { - type: string // 'string' | 'number' | 'boolean' | 'date' | etc. + type: string | string[] // 'string' | 'number' | 'boolean' | 'date' | etc., or an enum array required?: boolean unique?: boolean references?: { model: string field: string - onDelete?: 'cascade' | 'set null' | 'restrict' + onDelete?: 'no action' | 'restrict' | 'cascade' | 'set null' | 'set default' } defaultValue?: unknown returned?: boolean @@ -29,7 +29,7 @@ type BetterAuthFieldAttribute = { * Better Auth table schema structure */ type BetterAuthTableSchema = { - modelName: string + modelName?: string fields: Record } diff --git a/packages/auth/tests/server.test.ts b/packages/auth/tests/server.test.ts index 0883e812..11bcb9b1 100644 --- a/packages/auth/tests/server.test.ts +++ b/packages/auth/tests/server.test.ts @@ -481,6 +481,108 @@ describe('buildBetterAuthOptions / createAuth parity', () => { expect(betterAuthMock).toHaveBeenCalledTimes(1) expect(betterAuthMock.mock.calls[0][0]).toEqual(built) }) + + it('createAuth with a plugin tuple constructs betterAuth with exactly what buildBetterAuthOptions returns for the same tuple', async () => { + const pluginA = { id: 'plugin-a' } + const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] }) + const opensaasConfig = makeOpensaasConfig(authConfig) + const context = makeContext() + + const built = await buildBetterAuthOptions(opensaasConfig, context, [pluginA]) + + const auth = createAuth(opensaasConfig, context, [pluginA]) + await auth.api.getSession({}) + + expect(betterAuthMock).toHaveBeenCalledTimes(1) + expect(betterAuthMock.mock.calls[0][0]).toEqual(built) + }) + + it('createAuth rejects when its plugin tuple does not match the resolved betterAuthPlugins', async () => { + const pluginA = { id: 'plugin-a' } + const differentInstance = { id: 'plugin-a' } + const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] }) + const opensaasConfig = makeOpensaasConfig(authConfig) + const context = makeContext() + + const auth = createAuth(opensaasConfig, context, [differentInstance]) + + await expect(auth.api.getSession({})).rejects.toThrow( + /does not match the plugin array resolved/, + ) + expect(betterAuthMock).not.toHaveBeenCalled() + }) +}) + +describe('buildBetterAuthOptions plugin-tuple argument', () => { + beforeEach(() => { + betterAuthMock.mockClear() + prismaAdapterMock.mockClear() + nextCookiesMock.mockClear() + }) + + it('rejects when the supplied tuple has a different length than the resolved betterAuthPlugins', async () => { + const pluginA = { id: 'plugin-a' } + const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] }) + + await expect( + buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), []), + ).rejects.toThrow(/has 0 plugin\(s\), but the plugin array resolved.*has 1/) + }) + + it('rejects naming the mismatching index when a supplied plugin is not the same instance', async () => { + const pluginA = { id: 'plugin-a' } + const pluginB = { id: 'plugin-b' } + const differentInstance = { id: 'plugin-a' } // same id, different identity + + const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] }) + + await expect( + buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [ + differentInstance, + pluginB, + ]), + ).rejects.toThrow(/at index 0/) + }) + + it('rejects naming the mismatching index when the supplied order differs', async () => { + const pluginA = { id: 'plugin-a' } + const pluginB = { id: 'plugin-b' } + const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] }) + + await expect( + buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [pluginB, pluginA]), + ).rejects.toThrow(/at index 0/) + }) + + it('does not throw when the supplied tuple is the exact same instances in the same order', async () => { + const pluginA = { id: 'plugin-a' } + const pluginB = { id: 'plugin-b' } + const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] }) + + const config = await buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [ + pluginA, + pluginB, + ]) + + expect(config.plugins).toEqual([pluginA, pluginB, { id: 'next-cookies' }]) + }) + + it('appends exactly one nextCookies() plugin, last, whether or not a plugin tuple is supplied', async () => { + const pluginA = { id: 'plugin-a' } + const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] }) + const opensaasConfig = makeOpensaasConfig(authConfig) + const context = makeContext() + + const withoutArg = await buildBetterAuthOptions(opensaasConfig, context) + expect(nextCookiesMock).toHaveBeenCalledTimes(1) + expect(withoutArg.plugins).toEqual([pluginA, { id: 'next-cookies' }]) + + nextCookiesMock.mockClear() + + const withArg = await buildBetterAuthOptions(opensaasConfig, context, [pluginA]) + expect(nextCookiesMock).toHaveBeenCalledTimes(1) + expect(withArg.plugins).toEqual([pluginA, { id: 'next-cookies' }]) + }) }) describe('getSessionFromAuth', () => { diff --git a/packages/auth/vitest.config.ts b/packages/auth/vitest.config.ts index 50c41ef8..9069ab1c 100644 --- a/packages/auth/vitest.config.ts +++ b/packages/auth/vitest.config.ts @@ -1,9 +1,15 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig, defaultExclude } from 'vitest/config' import path from 'path' export default defineConfig({ test: { globals: true, + // The `test` turbo task depends on `build`, so a `dist/` directory is + // present when tests run in CI. Without this exclusion Vitest would also + // discover the compiled `dist/**/*.test.js` duplicate of the colocated + // type-level test in `src/server/`. Preserve Vitest's defaults and + // additionally ignore `dist`. + exclude: [...defaultExclude, '**/dist/**'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'json-summary'], diff --git a/packages/rag/src/runtime/embeddings.ts b/packages/rag/src/runtime/embeddings.ts index 54c2ff24..e06b98d8 100644 --- a/packages/rag/src/runtime/embeddings.ts +++ b/packages/rag/src/runtime/embeddings.ts @@ -81,16 +81,13 @@ export interface ChunkedEmbedding { export function generateEmbedding( options: GenerateEmbeddingOptions & { enableChunking: true }, ): Promise -// eslint-disable-next-line no-redeclare export function generateEmbedding( options: GenerateEmbeddingOptions & { enableChunking?: false }, ): Promise -// eslint-disable-next-line no-redeclare export function generateEmbedding( options: GenerateEmbeddingOptions, ): Promise // Implementation -// eslint-disable-next-line no-redeclare export async function generateEmbedding( options: GenerateEmbeddingOptions, ): Promise {