@opensaas/stack-auth 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,58 @@
1
1
  import { betterAuth } from 'better-auth'
2
2
  import { prismaAdapter } from 'better-auth/adapters/prisma'
3
3
  import { nextCookies } from 'better-auth/next-js'
4
- import type { BetterAuthOptions } from 'better-auth'
5
- import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
4
+ import type { Auth, BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
5
+ import type { OpenSaasConfig, AccessContext, Session } from '@opensaas/stack-core'
6
6
  import type { DatabaseConfig } from '@opensaas/stack-core/internal'
7
7
  import type { NormalizedAuthConfig, NormalizedAuthModelConfig } from '../config/types.js'
8
8
 
9
+ /**
10
+ * The `BetterAuthOptions` shape produced when an app's own plugin tuple is
11
+ * passed to `buildBetterAuthOptions()`/`createAuth()` — the tuple plus the
12
+ * `nextCookies()` plugin the stack always appends last. Carrying the literal
13
+ * tuple type (rather than the widened `BetterAuthPlugin[]`) is what lets
14
+ * `betterAuth()` re-infer plugin endpoints (e.g. `emailOTP()`'s
15
+ * `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
16
+ * shape from the resulting options object.
17
+ */
18
+ type ResolvedBetterAuthOptions<TPlugins extends readonly BetterAuthPlugin[]> = Omit<
19
+ BetterAuthOptions,
20
+ 'plugins'
21
+ > & {
22
+ plugins: [...TPlugins, ReturnType<typeof nextCookies>]
23
+ }
24
+
25
+ /**
26
+ * Guard against the supplied plugin tuple silently drifting from the plugin
27
+ * array actually resolved from `authPlugin({ betterAuthPlugins })` — the
28
+ * supplied tuple exists for typing only, so if it isn't the exact same
29
+ * instances in the exact same order, the type it produces would be a lie
30
+ * about what `betterAuth()` is actually constructed with.
31
+ */
32
+ function assertPluginTupleMatchesResolved(
33
+ supplied: readonly BetterAuthPlugin[],
34
+ resolved: readonly BetterAuthPlugin[],
35
+ ): void {
36
+ if (supplied.length !== resolved.length) {
37
+ throw new Error(
38
+ '[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' +
39
+ `has ${supplied.length} plugin(s), but the plugin array resolved from \`authPlugin({ ` +
40
+ `betterAuthPlugins })\` has ${resolved.length}. Pass the exact same array (without ` +
41
+ '`nextCookies()` — the stack appends that itself).',
42
+ )
43
+ }
44
+
45
+ const mismatchIndex = supplied.findIndex((plugin, index) => plugin !== resolved[index])
46
+ if (mismatchIndex !== -1) {
47
+ throw new Error(
48
+ '[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' +
49
+ `does not match the plugin array resolved from \`authPlugin({ betterAuthPlugins })\` at index ` +
50
+ `${mismatchIndex} (got plugin "${supplied[mismatchIndex]?.id}", expected the same instance as ` +
51
+ `"${resolved[mismatchIndex]?.id}"). Pass the exact same array — same instances, same order.`,
52
+ )
53
+ }
54
+ }
55
+
9
56
  /**
10
57
  * Get better-auth database configuration from OpenSaas config
11
58
  */
@@ -125,21 +172,55 @@ function mergeBetterAuthOptions(
125
172
  * authoritative for everything it models; the app's additions on top become
126
173
  * an explicit, reviewable diff instead of a parallel, hand-duplicated config.
127
174
  *
128
- * @example
175
+ * Called with just `(config, context)`, the return type is the widened
176
+ * `BetterAuthOptions` — `betterAuth()` infers its plugin/session types from
177
+ * the *literal* type of the options object, so constructing from this
178
+ * widened return erases plugin endpoints (e.g. `emailOTP()`'s
179
+ * `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
180
+ * shape. **If your app reads `auth.api.*` in typed code and uses either of
181
+ * those, pass its plugin tuple as the third argument** — the exact same
182
+ * array already passed to `authPlugin({ betterAuthPlugins })` — so the
183
+ * return type carries the literal tuple instead:
184
+ *
129
185
  * ```typescript
130
186
  * import { betterAuth } from 'better-auth'
187
+ * import { emailOTP } from 'better-auth/plugins'
131
188
  * import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
132
189
  *
190
+ * export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
191
+ *
133
192
  * export const auth = betterAuth({
134
- * ...(await buildBetterAuthOptions(config, context)),
193
+ * ...(await buildBetterAuthOptions(config, context, appBetterAuthPlugins)),
135
194
  * databaseHooks: { user: { create: { after: syncDomainUser } } },
136
195
  * })
196
+ * // auth.api.signInEmailOTP / auth.api.getSession()'s customSession shape are now typed.
137
197
  * ```
198
+ *
199
+ * The supplied tuple is for typing only — the array actually used at runtime
200
+ * is always the one resolved from `authPlugin({ betterAuthPlugins })`, with
201
+ * exactly one `nextCookies()` appended last. Passing a tuple that isn't the
202
+ * same plugin instances in the same order throws, so the two can't silently
203
+ * drift apart.
204
+ *
205
+ * Note `createAuth()`'s lazy Proxy does not behave identically to a real
206
+ * `Auth` instance for every property (see its own doc comment) — reach for
207
+ * this builder plus `betterAuth()` instead when the app reads `auth.api.*`
208
+ * in typed code.
138
209
  */
139
210
  export async function buildBetterAuthOptions(
140
211
  opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
141
212
  context: AccessContext | Promise<AccessContext>,
142
- ): Promise<BetterAuthOptions> {
213
+ ): Promise<BetterAuthOptions>
214
+ export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
215
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
216
+ context: AccessContext | Promise<AccessContext>,
217
+ plugins: TPlugins,
218
+ ): Promise<ResolvedBetterAuthOptions<TPlugins>>
219
+ export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
220
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
221
+ context: AccessContext | Promise<AccessContext>,
222
+ plugins?: TPlugins,
223
+ ): Promise<BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>> {
143
224
  const resolvedConfig = await Promise.resolve(opensaasConfig)
144
225
  const resolvedContext = await Promise.resolve(context)
145
226
 
@@ -179,6 +260,11 @@ export async function buildBetterAuthOptions(
179
260
 
180
261
  assertNoUnsupportedPassthroughKeys(authConfig.betterAuthOptions as Record<string, unknown>)
181
262
 
263
+ const resolvedPlugins = authConfig.betterAuthPlugins || []
264
+ if (plugins) {
265
+ assertPluginTupleMatchesResolved(plugins, resolvedPlugins)
266
+ }
267
+
182
268
  // Build better-auth configuration
183
269
  const betterAuthConfig: BetterAuthOptions = {
184
270
  database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
@@ -259,47 +345,81 @@ export async function buildBetterAuthOptions(
259
345
  // cookie store. This is what makes the server-action auth forms (which
260
346
  // call auth.api.signInEmail/signUpEmail/etc. server-side) actually
261
347
  // persist a session. It must be the final plugin in the array.
262
- plugins: [...(authConfig.betterAuthPlugins || []), nextCookies()],
348
+ plugins: [...resolvedPlugins, nextCookies()],
263
349
  }
264
350
 
265
351
  return mergeBetterAuthOptions(
266
352
  betterAuthConfig as unknown as Record<string, unknown>,
267
353
  authConfig.betterAuthOptions as Record<string, unknown>,
268
- ) as BetterAuthOptions
354
+ ) as BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>
269
355
  }
270
356
 
271
357
  /**
272
358
  * Create a better-auth instance from OpenSaas config
273
359
  * This should be called once at app startup
274
360
  *
275
- * @example
361
+ * Returns a lazy `Proxy` (see the caveat below), typed as `Auth<BetterAuthOptions>`
362
+ * when called with just `(config, context)` — the widened type, same erasure
363
+ * caveat as {@link buildBetterAuthOptions}'s no-argument form. **If your app
364
+ * reads `auth.api.*` in typed code and relies on a plugin's endpoints (e.g.
365
+ * `emailOTP()`) or a `customSession()`'s replaced session shape, pass its
366
+ * plugin tuple as the third argument** — the exact same array already passed
367
+ * to `authPlugin({ betterAuthPlugins })` — so the declared type carries the
368
+ * literal tuple instead:
369
+ *
276
370
  * ```typescript
277
371
  * // lib/auth.ts
278
372
  * import { createAuth } from '@opensaas/stack-auth/server'
279
373
  * import config from '../opensaas.config'
280
374
  * import { rawOpensaasContext } from '@/.opensaas/context'
281
375
  *
282
- * export const auth = createAuth(config, rawOpensaasContext)
376
+ * export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
377
+ *
378
+ * export const auth = createAuth(config, rawOpensaasContext, appBetterAuthPlugins)
283
379
  * ```
380
+ *
381
+ * As with the builder, the supplied tuple is for typing only, and a tuple
382
+ * that isn't the same plugin instances in the same order throws.
383
+ *
384
+ * **Proxy caveat:** the lazy `Proxy` this returns does not behave identically
385
+ * to a real `Auth` instance for every property — every access, including a
386
+ * non-function property, is surfaced through an `async` wrapper (so e.g.
387
+ * `auth.options` reads back as a `Promise`, not the plain object a real
388
+ * instance would return synchronously). The declared type does not model
389
+ * this difference; where it matters, reach for {@link buildBetterAuthOptions}
390
+ * plus `betterAuth()` instead, which constructs a real instance.
284
391
  */
285
392
  export function createAuth(
286
393
  opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
287
394
  context: AccessContext | Promise<AccessContext>,
288
- ) {
395
+ ): Auth<BetterAuthOptions>
396
+ export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
397
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
398
+ context: AccessContext | Promise<AccessContext>,
399
+ plugins: TPlugins,
400
+ ): Auth<ResolvedBetterAuthOptions<TPlugins>>
401
+ export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
402
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
403
+ context: AccessContext | Promise<AccessContext>,
404
+ plugins?: TPlugins,
405
+ ): Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>> {
289
406
  // Resolve config and context asynchronously
290
407
  const configPromise = Promise.resolve(opensaasConfig)
291
408
  const contextPromise = Promise.resolve(context)
292
409
 
293
410
  // Create auth instance lazily when needed
294
- let authInstance: ReturnType<typeof betterAuth> | null = null
295
- let authPromise: Promise<ReturnType<typeof betterAuth>> | null = null
411
+ type AuthInstance = Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>>
412
+ let authInstance: AuthInstance | null = null
413
+ let authPromise: Promise<AuthInstance> | null = null
296
414
 
297
415
  async function getAuthInstance() {
298
416
  if (authInstance) return authInstance
299
417
 
300
418
  if (!authPromise) {
301
419
  authPromise = (async () => {
302
- const betterAuthConfig = await buildBetterAuthOptions(configPromise, contextPromise)
420
+ const betterAuthConfig = plugins
421
+ ? await buildBetterAuthOptions(configPromise, contextPromise, plugins)
422
+ : await buildBetterAuthOptions(configPromise, contextPromise)
303
423
  authInstance = betterAuth(betterAuthConfig)
304
424
  return authInstance
305
425
  })()
@@ -309,7 +429,7 @@ export function createAuth(
309
429
  }
310
430
 
311
431
  // Return a proxy that lazily initializes the auth instance
312
- return new Proxy({} as ReturnType<typeof betterAuth>, {
432
+ return new Proxy({} as AuthInstance, {
313
433
  get(_, prop) {
314
434
  if (prop === 'then') {
315
435
  // Support await on the proxy itself
@@ -355,41 +475,121 @@ export function createAuth(
355
475
  }
356
476
 
357
477
  /**
358
- * Get session from better-auth and transform it to OpenSaas session format.
478
+ * Field names already warned about failing to resolve against a session, so a
479
+ * given field warns at most once per process rather than once per request.
480
+ */
481
+ const unresolvedSessionFieldWarnings = new Set<string>()
482
+
483
+ /**
484
+ * Warn (once per field, per process) that a `sessionFields` entry could not
485
+ * be resolved from the session shape `auth.api.getSession()` actually
486
+ * returned — naming the field and what keys were available to check, so the
487
+ * gap is visible here instead of surfacing later as an access-control
488
+ * function silently reading `undefined`.
489
+ */
490
+ function warnUnresolvedSessionField(field: string, resolvedSession: Record<string, unknown>): void {
491
+ if (unresolvedSessionFieldWarnings.has(field)) return
492
+ unresolvedSessionFieldWarnings.add(field)
493
+
494
+ const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
495
+ const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
496
+
497
+ console.warn(
498
+ `[@opensaas/stack-auth] sessionFields: "${field}" was not found on the resolved session. ` +
499
+ `Checked its top-level keys (${Object.keys(resolvedSession).join(', ') || 'none'}), ` +
500
+ `its "user" object (${user ? Object.keys(user).join(', ') || 'none' : 'not present'}), ` +
501
+ `and its "session" object (${sessionRow ? Object.keys(sessionRow).join(', ') || 'none' : 'not present'}). ` +
502
+ `The field is omitted from the projected session. A \`customSession\` plugin that nests this ` +
503
+ `value elsewhere is the app's own to reconcile — see the \`sessionFields\` reference. ` +
504
+ `This warning will not repeat for "${field}".`,
505
+ )
506
+ }
507
+
508
+ /**
509
+ * Resolve a single `sessionFields` entry off the resolved better-auth
510
+ * session (whatever `auth.api.getSession()` returned — the default `{
511
+ * session, user }` shape, or a `customSession` plugin's replaced shape).
512
+ *
513
+ * `userId` is special-cased to the authenticated user's `id` — the
514
+ * documented default apps depend on. Every other name resolves against a
515
+ * fixed precedence so a collision between sources is predictable rather
516
+ * than incidental: a top-level key on the resolved session object, then the
517
+ * `user` object, then the `session` sub-object.
518
+ */
519
+ function resolveSessionField(
520
+ field: string,
521
+ resolvedSession: Record<string, unknown>,
522
+ ): { found: true; value: unknown } | { found: false } {
523
+ const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
524
+
525
+ if (field === 'userId') {
526
+ return user && 'id' in user ? { found: true, value: user.id } : { found: false }
527
+ }
528
+
529
+ if (field in resolvedSession) {
530
+ return { found: true, value: resolvedSession[field] }
531
+ }
532
+ if (user && field in user) {
533
+ return { found: true, value: user[field] }
534
+ }
535
+ const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
536
+ if (sessionRow && field in sessionRow) {
537
+ return { found: true, value: sessionRow[field] }
538
+ }
539
+ return { found: false }
540
+ }
541
+
542
+ /**
543
+ * Get session from better-auth and transform it to OpenSaas session format —
544
+ * a flattened projection of `sessionFields` off the *resolved* session
545
+ * object, not just its `user` sub-object. This is what makes a
546
+ * `customSession` plugin's fields (added at the top level, or a
547
+ * session-only field like the admin plugin's `impersonatedBy`) reachable.
548
+ * See the `sessionFields` reference for the resolution precedence.
549
+ *
550
+ * Returns `null` only when there is genuinely no session — a resolved
551
+ * session with no `user` key (a `customSession` plugin that dropped it) is
552
+ * still a session and still gets projected, never misreported as anonymous.
553
+ * A listed field that can't be resolved from the session shape is omitted
554
+ * and warns once per field per process (see `warnUnresolvedSessionField`)
555
+ * instead of silently vanishing into an access-control function reading
556
+ * `undefined`.
359
557
  *
360
- * Not called by any generated code today — apps currently hand-roll this same
361
- * transform against `auth.api.getSession({ headers: await headers() })` (see
362
- * `examples/starter-auth/lib/auth.ts`). Exported as a reusable helper for that
363
- * pattern; pass the caller's request headers (e.g. Next.js `await headers()`
364
- * in a Server Component/action) so a session cookie can actually be resolved.
558
+ * Errors from the underlying `auth.api.getSession()` call propagate rather
559
+ * than becoming `null` collapsing a lookup failure (e.g. a session-store
560
+ * outage) into "anonymous" is indistinguishable from a mass sign-out under
561
+ * fail-closed access control, so the caller must see it.
562
+ *
563
+ * Not called by any generated code before this helper existed — apps used to
564
+ * hand-roll this same transform against `auth.api.getSession({ headers:
565
+ * await headers() })`. Exported as the single reusable implementation; pass
566
+ * the caller's request headers (e.g. Next.js `await headers()` in a Server
567
+ * Component/action) so a session cookie can actually be resolved.
365
568
  */
366
569
  export async function getSessionFromAuth(
367
570
  auth: ReturnType<typeof betterAuth>,
368
571
  sessionFields: string[],
369
572
  headers: Headers,
370
- ) {
371
- try {
372
- const session = await auth.api.getSession({ headers })
573
+ ): Promise<Session | null> {
574
+ const resolvedSession = await auth.api.getSession({ headers })
373
575
 
374
- if (!session?.user) {
375
- return null
376
- }
576
+ if (!resolvedSession) {
577
+ return null
578
+ }
377
579
 
378
- // Build session object with requested fields
379
- const result: Record<string, unknown> = {}
580
+ const resolvedSessionRecord = resolvedSession as Record<string, unknown>
581
+ const result: Record<string, unknown> = {}
380
582
 
381
- for (const field of sessionFields) {
382
- if (field === 'userId') {
383
- result.userId = session.user.id
384
- } else if (field in session.user) {
385
- result[field] = session.user[field as keyof typeof session.user]
386
- }
583
+ for (const field of sessionFields) {
584
+ const resolved = resolveSessionField(field, resolvedSessionRecord)
585
+ if (resolved.found) {
586
+ result[field] = resolved.value
587
+ } else {
588
+ warnUnresolvedSessionField(field, resolvedSessionRecord)
387
589
  }
388
-
389
- return result
390
- } catch {
391
- return null
392
590
  }
591
+
592
+ return result
393
593
  }
394
594
 
395
595
  export type { BetterAuthOptions }
@@ -12,13 +12,13 @@ import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
12
12
  * Inferred from better-auth internal types
13
13
  */
14
14
  type BetterAuthFieldAttribute = {
15
- type: string // 'string' | 'number' | 'boolean' | 'date' | etc.
15
+ type: string | string[] // 'string' | 'number' | 'boolean' | 'date' | etc., or an enum array
16
16
  required?: boolean
17
17
  unique?: boolean
18
18
  references?: {
19
19
  model: string
20
20
  field: string
21
- onDelete?: 'cascade' | 'set null' | 'restrict'
21
+ onDelete?: 'no action' | 'restrict' | 'cascade' | 'set null' | 'set default'
22
22
  }
23
23
  defaultValue?: unknown
24
24
  returned?: boolean
@@ -29,7 +29,7 @@ type BetterAuthFieldAttribute = {
29
29
  * Better Auth table schema structure
30
30
  */
31
31
  type BetterAuthTableSchema = {
32
- modelName: string
32
+ modelName?: string
33
33
  fields: Record<string, BetterAuthFieldAttribute>
34
34
  }
35
35
 
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import type { BetterAuthOptions } from 'better-auth'
3
3
  import type { NormalizedAuthConfig } from '../src/config/types.js'
4
4
  import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
@@ -481,6 +481,108 @@ describe('buildBetterAuthOptions / createAuth parity', () => {
481
481
  expect(betterAuthMock).toHaveBeenCalledTimes(1)
482
482
  expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
483
483
  })
484
+
485
+ it('createAuth with a plugin tuple constructs betterAuth with exactly what buildBetterAuthOptions returns for the same tuple', async () => {
486
+ const pluginA = { id: 'plugin-a' }
487
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
488
+ const opensaasConfig = makeOpensaasConfig(authConfig)
489
+ const context = makeContext()
490
+
491
+ const built = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
492
+
493
+ const auth = createAuth(opensaasConfig, context, [pluginA])
494
+ await auth.api.getSession({})
495
+
496
+ expect(betterAuthMock).toHaveBeenCalledTimes(1)
497
+ expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
498
+ })
499
+
500
+ it('createAuth rejects when its plugin tuple does not match the resolved betterAuthPlugins', async () => {
501
+ const pluginA = { id: 'plugin-a' }
502
+ const differentInstance = { id: 'plugin-a' }
503
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
504
+ const opensaasConfig = makeOpensaasConfig(authConfig)
505
+ const context = makeContext()
506
+
507
+ const auth = createAuth(opensaasConfig, context, [differentInstance])
508
+
509
+ await expect(auth.api.getSession({})).rejects.toThrow(
510
+ /does not match the plugin array resolved/,
511
+ )
512
+ expect(betterAuthMock).not.toHaveBeenCalled()
513
+ })
514
+ })
515
+
516
+ describe('buildBetterAuthOptions plugin-tuple argument', () => {
517
+ beforeEach(() => {
518
+ betterAuthMock.mockClear()
519
+ prismaAdapterMock.mockClear()
520
+ nextCookiesMock.mockClear()
521
+ })
522
+
523
+ it('rejects when the supplied tuple has a different length than the resolved betterAuthPlugins', async () => {
524
+ const pluginA = { id: 'plugin-a' }
525
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
526
+
527
+ await expect(
528
+ buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), []),
529
+ ).rejects.toThrow(/has 0 plugin\(s\), but the plugin array resolved.*has 1/)
530
+ })
531
+
532
+ it('rejects naming the mismatching index when a supplied plugin is not the same instance', async () => {
533
+ const pluginA = { id: 'plugin-a' }
534
+ const pluginB = { id: 'plugin-b' }
535
+ const differentInstance = { id: 'plugin-a' } // same id, different identity
536
+
537
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
538
+
539
+ await expect(
540
+ buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
541
+ differentInstance,
542
+ pluginB,
543
+ ]),
544
+ ).rejects.toThrow(/at index 0/)
545
+ })
546
+
547
+ it('rejects naming the mismatching index when the supplied order differs', async () => {
548
+ const pluginA = { id: 'plugin-a' }
549
+ const pluginB = { id: 'plugin-b' }
550
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
551
+
552
+ await expect(
553
+ buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [pluginB, pluginA]),
554
+ ).rejects.toThrow(/at index 0/)
555
+ })
556
+
557
+ it('does not throw when the supplied tuple is the exact same instances in the same order', async () => {
558
+ const pluginA = { id: 'plugin-a' }
559
+ const pluginB = { id: 'plugin-b' }
560
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
561
+
562
+ const config = await buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
563
+ pluginA,
564
+ pluginB,
565
+ ])
566
+
567
+ expect(config.plugins).toEqual([pluginA, pluginB, { id: 'next-cookies' }])
568
+ })
569
+
570
+ it('appends exactly one nextCookies() plugin, last, whether or not a plugin tuple is supplied', async () => {
571
+ const pluginA = { id: 'plugin-a' }
572
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
573
+ const opensaasConfig = makeOpensaasConfig(authConfig)
574
+ const context = makeContext()
575
+
576
+ const withoutArg = await buildBetterAuthOptions(opensaasConfig, context)
577
+ expect(nextCookiesMock).toHaveBeenCalledTimes(1)
578
+ expect(withoutArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
579
+
580
+ nextCookiesMock.mockClear()
581
+
582
+ const withArg = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
583
+ expect(nextCookiesMock).toHaveBeenCalledTimes(1)
584
+ expect(withArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
585
+ })
484
586
  })
485
587
 
486
588
  describe('getSessionFromAuth', () => {
@@ -504,14 +606,118 @@ describe('getSessionFromAuth', () => {
504
606
  expect(result).toBeNull()
505
607
  })
506
608
 
507
- it('returns null when auth.api.getSession throws', async () => {
609
+ it('propagates an error thrown by the underlying session lookup, distinguishable from no session', async () => {
508
610
  const getSession = vi.fn(async () => {
509
611
  throw new Error('boom')
510
612
  })
511
613
  const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
512
614
 
513
- const result = await getSessionFromAuth(auth, ['userId'], new Headers())
615
+ await expect(getSessionFromAuth(auth, ['userId'], new Headers())).rejects.toThrow('boom')
616
+ })
514
617
 
515
- expect(result).toBeNull()
618
+ it('resolves the documented happy path unchanged: fields on the user, userId from user.id', async () => {
619
+ const getSession = vi.fn(async () => ({
620
+ user: { id: 'user-1', email: 'a@b.com', name: 'Ada' },
621
+ }))
622
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
623
+
624
+ const result = await getSessionFromAuth(auth, ['userId', 'email', 'name'], new Headers())
625
+
626
+ expect(result).toEqual({ userId: 'user-1', email: 'a@b.com', name: 'Ada' })
627
+ })
628
+
629
+ it('projects a customSession shape with no top-level user key instead of reporting anonymous', async () => {
630
+ // A customSession plugin can fully replace the resolved shape (e.g.
631
+ // nesting fields under a custom key) and drop the `user` object entirely
632
+ // — that must still be treated as "a session", not "no session".
633
+ const getSession = vi.fn(async () => ({
634
+ email: 'nested@example.com',
635
+ data: { role: 'admin' },
636
+ }))
637
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
638
+
639
+ const result = await getSessionFromAuth(auth, ['email'], new Headers())
640
+
641
+ expect(result).not.toBeNull()
642
+ expect(result).toEqual({ email: 'nested@example.com' })
643
+ })
644
+
645
+ it('resolves a field living on the session sub-object, not just the user', async () => {
646
+ const getSession = vi.fn(async () => ({
647
+ user: { id: 'user-1' },
648
+ session: { impersonatedBy: 'admin-1' },
649
+ }))
650
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
651
+
652
+ const result = await getSessionFromAuth(auth, ['userId', 'impersonatedBy'], new Headers())
653
+
654
+ expect(result).toEqual({ userId: 'user-1', impersonatedBy: 'admin-1' })
655
+ })
656
+
657
+ describe('resolution precedence', () => {
658
+ it('prefers a top-level key over the same name on user or session (deliberate collision)', async () => {
659
+ const getSession = vi.fn(async () => ({
660
+ role: 'top-level-role',
661
+ user: { role: 'user-role' },
662
+ session: { role: 'session-role' },
663
+ }))
664
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
665
+
666
+ const result = await getSessionFromAuth(auth, ['role'], new Headers())
667
+
668
+ expect(result).toEqual({ role: 'top-level-role' })
669
+ })
670
+
671
+ it('prefers the user object over the session sub-object when there is no top-level key', async () => {
672
+ const getSession = vi.fn(async () => ({
673
+ user: { role: 'user-role' },
674
+ session: { role: 'session-role' },
675
+ }))
676
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
677
+
678
+ const result = await getSessionFromAuth(auth, ['role'], new Headers())
679
+
680
+ expect(result).toEqual({ role: 'user-role' })
681
+ })
682
+ })
683
+
684
+ // The warn-once cache is module-level state, so these tests re-import the
685
+ // module fresh via vi.resetModules() — same pattern as the `select` no-op
686
+ // warning tests in packages/core/tests/context.test.ts.
687
+ describe('unresolved field warning', () => {
688
+ let warnSpy: ReturnType<typeof vi.spyOn>
689
+ let freshGetSessionFromAuth: typeof getSessionFromAuth
690
+
691
+ beforeEach(async () => {
692
+ vi.resetModules()
693
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
694
+ const mod = await import('../src/server/index.js')
695
+ freshGetSessionFromAuth = mod.getSessionFromAuth
696
+ })
697
+
698
+ afterEach(() => {
699
+ warnSpy.mockRestore()
700
+ })
701
+
702
+ it('omits an unresolvable field, warns once naming it, and does not throw', async () => {
703
+ const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
704
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
705
+
706
+ const result = await freshGetSessionFromAuth(auth, ['userId', 'nickname'], new Headers())
707
+
708
+ expect(result).toEqual({ userId: 'user-1' })
709
+ expect(warnSpy).toHaveBeenCalledTimes(1)
710
+ expect(warnSpy.mock.calls[0][0]).toContain('"nickname"')
711
+ })
712
+
713
+ it('does not warn again for the same field on a second call', async () => {
714
+ const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
715
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
716
+
717
+ await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
718
+ await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
719
+
720
+ expect(warnSpy).toHaveBeenCalledTimes(1)
721
+ })
516
722
  })
517
723
  })