@opensaas/stack-auth 0.36.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.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +146 -0
  3. package/CLAUDE.md +153 -9
  4. package/README.md +18 -7
  5. package/dist/config/adopt-better-auth-tables.d.ts +47 -0
  6. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  7. package/dist/config/adopt-better-auth-tables.js +29 -1
  8. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  9. package/dist/config/derive-auth-lists.d.ts +7 -5
  10. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  11. package/dist/config/derive-auth-lists.js +33 -34
  12. package/dist/config/derive-auth-lists.js.map +1 -1
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +41 -11
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/plugin.d.ts.map +1 -1
  17. package/dist/config/plugin.js +39 -27
  18. package/dist/config/plugin.js.map +1 -1
  19. package/dist/config/types.d.ts +143 -28
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/server/build-better-auth-options.test.d.ts +2 -0
  22. package/dist/server/build-better-auth-options.test.d.ts.map +1 -0
  23. package/dist/server/build-better-auth-options.test.js +29 -0
  24. package/dist/server/build-better-auth-options.test.js.map +1 -0
  25. package/dist/server/index.d.ts +112 -8
  26. package/dist/server/index.d.ts.map +1 -1
  27. package/dist/server/index.js +284 -96
  28. package/dist/server/index.js.map +1 -1
  29. package/dist/server/schema-converter.d.ts +3 -3
  30. package/dist/server/schema-converter.d.ts.map +1 -1
  31. package/package.json +5 -5
  32. package/src/config/adopt-better-auth-tables.ts +70 -1
  33. package/src/config/derive-auth-lists.ts +37 -38
  34. package/src/config/index.ts +47 -12
  35. package/src/config/plugin.ts +40 -28
  36. package/src/config/types.ts +144 -27
  37. package/src/server/build-better-auth-options.test.ts +59 -0
  38. package/src/server/index.ts +470 -106
  39. package/src/server/schema-converter.ts +3 -3
  40. package/tests/adopt-better-auth-tables.test.ts +99 -0
  41. package/tests/config.test.ts +66 -8
  42. package/tests/derive-auth-lists.test.ts +79 -5
  43. package/tests/generated-fk-shape.test.ts +65 -0
  44. package/tests/plugin-derived-keys.test.ts +48 -0
  45. package/tests/server.test.ts +723 -0
  46. package/tsconfig.tsbuildinfo +1 -1
  47. package/vitest.config.ts +7 -1
@@ -0,0 +1,723 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import type { BetterAuthOptions } from 'better-auth'
3
+ import type { NormalizedAuthConfig } from '../src/config/types.js'
4
+ import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
5
+
6
+ const betterAuthMock = vi.fn(() => ({ api: { getSession: vi.fn(async () => null) } }))
7
+ const prismaAdapterMock = vi.fn((client: unknown, opts: unknown) => ({ client, opts }))
8
+ const nextCookiesMock = vi.fn(() => ({ id: 'next-cookies' }))
9
+
10
+ vi.mock('better-auth', () => ({
11
+ betterAuth: betterAuthMock,
12
+ }))
13
+
14
+ vi.mock('better-auth/adapters/prisma', () => ({
15
+ prismaAdapter: prismaAdapterMock,
16
+ }))
17
+
18
+ vi.mock('better-auth/next-js', () => ({
19
+ nextCookies: nextCookiesMock,
20
+ }))
21
+
22
+ const { createAuth, buildBetterAuthOptions, getSessionFromAuth } =
23
+ await import('../src/server/index.js')
24
+
25
+ function makeAuthConfig(overrides: Partial<NormalizedAuthConfig> = {}): NormalizedAuthConfig {
26
+ return {
27
+ emailAndPassword: {
28
+ enabled: true,
29
+ minPasswordLength: 8,
30
+ requireConfirmation: true,
31
+ sendResetPassword: vi.fn(async () => {}),
32
+ },
33
+ emailVerification: {
34
+ enabled: false,
35
+ sendOnSignUp: true,
36
+ tokenExpiration: 86400,
37
+ sendVerificationEmail: vi.fn(async () => {}),
38
+ },
39
+ passwordReset: { enabled: false, tokenExpiration: 3600 },
40
+ socialProviders: {},
41
+ session: { expiresIn: 604800, updateAge: 86400 },
42
+ models: {
43
+ user: { modelName: 'User', fields: {} },
44
+ session: { modelName: 'Session', fields: {} },
45
+ account: { modelName: 'Account', fields: {} },
46
+ verification: { modelName: 'Verification', fields: {} },
47
+ },
48
+ sessionFields: ['userId', 'email', 'name'],
49
+ extendUserList: {},
50
+ access: {},
51
+ betterAuthPlugins: [],
52
+ rateLimit: undefined,
53
+ betterAuthOptions: {},
54
+ ...overrides,
55
+ }
56
+ }
57
+
58
+ function makeOpensaasConfig(authConfig: NormalizedAuthConfig): OpenSaasConfig {
59
+ return {
60
+ db: { provider: 'sqlite' },
61
+ lists: {},
62
+ _pluginData: { auth: authConfig },
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal test fixture
64
+ } as any
65
+ }
66
+
67
+ function makeContext(): AccessContext {
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal test fixture
69
+ return { prisma: { __mockPrisma: true } } as any
70
+ }
71
+
72
+ async function buildBetterAuthConfig(authConfig: NormalizedAuthConfig): Promise<BetterAuthOptions> {
73
+ const auth = createAuth(makeOpensaasConfig(authConfig), makeContext())
74
+ // Calling a nested method is what actually forces the lazy proxy to resolve
75
+ // the underlying betterAuth() instance — merely accessing `auth.api` does not.
76
+ await auth.api.getSession({})
77
+ expect(betterAuthMock).toHaveBeenCalledTimes(1)
78
+ return betterAuthMock.mock.calls[0][0] as BetterAuthOptions
79
+ }
80
+
81
+ describe('createAuth', () => {
82
+ beforeEach(() => {
83
+ betterAuthMock.mockClear()
84
+ prismaAdapterMock.mockClear()
85
+ nextCookiesMock.mockClear()
86
+ })
87
+
88
+ it('forwards emailAndPassword.minPasswordLength', async () => {
89
+ const config = await buildBetterAuthConfig(
90
+ makeAuthConfig({
91
+ emailAndPassword: {
92
+ enabled: true,
93
+ minPasswordLength: 12,
94
+ requireConfirmation: true,
95
+ sendResetPassword: vi.fn(async () => {}),
96
+ },
97
+ }),
98
+ )
99
+
100
+ expect(config.emailAndPassword).toMatchObject({ enabled: true, minPasswordLength: 12 })
101
+ })
102
+
103
+ it('omits emailAndPassword entirely when disabled', async () => {
104
+ const config = await buildBetterAuthConfig(
105
+ makeAuthConfig({
106
+ emailAndPassword: {
107
+ enabled: false,
108
+ minPasswordLength: 8,
109
+ requireConfirmation: true,
110
+ sendResetPassword: vi.fn(async () => {}),
111
+ },
112
+ }),
113
+ )
114
+
115
+ expect(config.emailAndPassword).toBeUndefined()
116
+ })
117
+
118
+ it('forwards emailVerification.sendOnSignUp and tokenExpiration when enabled', async () => {
119
+ const config = await buildBetterAuthConfig(
120
+ makeAuthConfig({
121
+ emailVerification: {
122
+ enabled: true,
123
+ sendOnSignUp: false,
124
+ tokenExpiration: 1234,
125
+ sendVerificationEmail: vi.fn(async () => {}),
126
+ },
127
+ }),
128
+ )
129
+
130
+ expect(config.emailVerification).toBeDefined()
131
+ expect(config.emailVerification?.sendOnSignUp).toBe(false)
132
+ expect(config.emailVerification?.expiresIn).toBe(1234)
133
+ expect(typeof config.emailVerification?.sendVerificationEmail).toBe('function')
134
+ })
135
+
136
+ it('omits emailVerification entirely when disabled', async () => {
137
+ const config = await buildBetterAuthConfig(
138
+ makeAuthConfig({
139
+ emailVerification: {
140
+ enabled: false,
141
+ sendOnSignUp: true,
142
+ tokenExpiration: 86400,
143
+ sendVerificationEmail: vi.fn(async () => {}),
144
+ },
145
+ }),
146
+ )
147
+
148
+ expect(config.emailVerification).toBeUndefined()
149
+ })
150
+
151
+ it('forwards the configured sendVerificationEmail callback straight through, unwrapped', async () => {
152
+ const sendVerificationEmail = vi.fn(async () => {})
153
+ const config = await buildBetterAuthConfig(
154
+ makeAuthConfig({
155
+ emailVerification: {
156
+ enabled: true,
157
+ sendOnSignUp: true,
158
+ tokenExpiration: 86400,
159
+ sendVerificationEmail,
160
+ },
161
+ }),
162
+ )
163
+
164
+ expect(config.emailVerification?.sendVerificationEmail).toBe(sendVerificationEmail)
165
+ })
166
+
167
+ it('forwards passwordReset.tokenExpiration and the configured sendResetPassword callback straight through, unwrapped', async () => {
168
+ const sendResetPassword = vi.fn(async () => {})
169
+ const config = await buildBetterAuthConfig(
170
+ makeAuthConfig({
171
+ passwordReset: { enabled: true, tokenExpiration: 4321 },
172
+ emailAndPassword: {
173
+ enabled: true,
174
+ minPasswordLength: 8,
175
+ requireConfirmation: true,
176
+ sendResetPassword,
177
+ },
178
+ }),
179
+ )
180
+
181
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
182
+ const emailAndPassword = config.emailAndPassword as any
183
+ expect(emailAndPassword.resetPasswordTokenExpiresIn).toBe(4321)
184
+ expect(emailAndPassword.sendResetPassword).toBe(sendResetPassword)
185
+ })
186
+
187
+ it('does not add sendResetPassword when passwordReset is disabled', async () => {
188
+ const config = await buildBetterAuthConfig(
189
+ makeAuthConfig({ passwordReset: { enabled: false, tokenExpiration: 3600 } }),
190
+ )
191
+
192
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
193
+ const emailAndPassword = config.emailAndPassword as any
194
+ expect(emailAndPassword.sendResetPassword).toBeUndefined()
195
+ expect(emailAndPassword.resetPasswordTokenExpiresIn).toBeUndefined()
196
+ })
197
+
198
+ it('passes session.updateAge through as a number', async () => {
199
+ const config = await buildBetterAuthConfig(
200
+ makeAuthConfig({ session: { expiresIn: 604800, updateAge: 3600 } }),
201
+ )
202
+
203
+ expect(config.session?.updateAge).toBe(3600)
204
+ })
205
+
206
+ it('sets disableSessionRefresh instead of updateAge: 0 when updateAge is false', async () => {
207
+ const config = await buildBetterAuthConfig(
208
+ makeAuthConfig({ session: { expiresIn: 604800, updateAge: false } }),
209
+ )
210
+
211
+ // better-auth treats `updateAge: 0` as "refresh on every request", not
212
+ // "never refresh" — disabling refresh requires the separate flag.
213
+ expect(config.session?.disableSessionRefresh).toBe(true)
214
+ expect(config.session?.updateAge).toBeUndefined()
215
+ })
216
+
217
+ it('warns when requireConfirmation is set to false since it has no server-side effect', async () => {
218
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
219
+
220
+ await buildBetterAuthConfig(
221
+ makeAuthConfig({
222
+ emailAndPassword: {
223
+ enabled: true,
224
+ minPasswordLength: 8,
225
+ requireConfirmation: false,
226
+ sendResetPassword: vi.fn(async () => {}),
227
+ },
228
+ }),
229
+ )
230
+
231
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('requireConfirmation'))
232
+ warnSpy.mockRestore()
233
+ })
234
+
235
+ it('does not warn when requireConfirmation is left at its default (true)', async () => {
236
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
237
+
238
+ await buildBetterAuthConfig(
239
+ makeAuthConfig({
240
+ emailAndPassword: {
241
+ enabled: true,
242
+ minPasswordLength: 8,
243
+ requireConfirmation: true,
244
+ sendResetPassword: vi.fn(async () => {}),
245
+ },
246
+ }),
247
+ )
248
+
249
+ expect(warnSpy).not.toHaveBeenCalled()
250
+ warnSpy.mockRestore()
251
+ })
252
+
253
+ it('does not warn about requireConfirmation when emailAndPassword is disabled', async () => {
254
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
255
+
256
+ await buildBetterAuthConfig(
257
+ makeAuthConfig({
258
+ emailAndPassword: {
259
+ enabled: false,
260
+ minPasswordLength: 8,
261
+ requireConfirmation: false,
262
+ sendResetPassword: vi.fn(async () => {}),
263
+ },
264
+ }),
265
+ )
266
+
267
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('requireConfirmation'))
268
+ warnSpy.mockRestore()
269
+ })
270
+
271
+ it('warns when passwordReset is enabled but emailAndPassword is disabled', async () => {
272
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
273
+
274
+ await buildBetterAuthConfig(
275
+ makeAuthConfig({
276
+ emailAndPassword: {
277
+ enabled: false,
278
+ minPasswordLength: 8,
279
+ requireConfirmation: true,
280
+ sendResetPassword: vi.fn(async () => {}),
281
+ },
282
+ passwordReset: { enabled: true, tokenExpiration: 3600 },
283
+ }),
284
+ )
285
+
286
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('passwordReset'))
287
+ warnSpy.mockRestore()
288
+ })
289
+
290
+ it('does not warn about passwordReset when emailAndPassword is enabled', async () => {
291
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
292
+
293
+ await buildBetterAuthConfig(
294
+ makeAuthConfig({
295
+ emailAndPassword: {
296
+ enabled: true,
297
+ minPasswordLength: 8,
298
+ requireConfirmation: true,
299
+ sendResetPassword: vi.fn(async () => {}),
300
+ },
301
+ passwordReset: { enabled: true, tokenExpiration: 3600 },
302
+ }),
303
+ )
304
+
305
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('passwordReset'))
306
+ warnSpy.mockRestore()
307
+ })
308
+ })
309
+
310
+ describe('betterAuthOptions passthrough', () => {
311
+ beforeEach(() => {
312
+ betterAuthMock.mockClear()
313
+ prismaAdapterMock.mockClear()
314
+ nextCookiesMock.mockClear()
315
+ })
316
+
317
+ it('produces byte-for-byte identical options to today when unused', async () => {
318
+ // `normalizeAuthConfig` is what actually defaults a config without
319
+ // `betterAuthOptions` to `{}` (see config.test.ts) — reproduce that
320
+ // realistic normalized shape here rather than an AuthConfig missing a
321
+ // required NormalizedAuthConfig field.
322
+ const authConfig = makeAuthConfig({ betterAuthOptions: {} })
323
+
324
+ const built = await buildBetterAuthConfig(authConfig)
325
+
326
+ expect(built).toEqual({
327
+ database: { client: { __mockPrisma: true }, opts: { provider: 'sqlite' } },
328
+ user: { modelName: 'User' },
329
+ session: { modelName: 'Session', expiresIn: 604800, updateAge: 86400 },
330
+ account: { modelName: 'Account' },
331
+ verification: { modelName: 'Verification' },
332
+ emailAndPassword: {
333
+ enabled: true,
334
+ requireEmailVerification: false,
335
+ minPasswordLength: 8,
336
+ },
337
+ emailVerification: undefined,
338
+ trustedOrigins: [],
339
+ socialProviders: {},
340
+ rateLimit: undefined,
341
+ plugins: [{ id: 'next-cookies' }],
342
+ })
343
+ })
344
+
345
+ it('surfaces a top-level option the stack does not model (e.g. baseURL)', async () => {
346
+ const config = await buildBetterAuthConfig(
347
+ makeAuthConfig({ betterAuthOptions: { baseURL: 'https://example.com' } }),
348
+ )
349
+
350
+ expect(config.baseURL).toBe('https://example.com')
351
+ })
352
+
353
+ it('merges a nested database hook without clobbering sibling top-level keys', async () => {
354
+ const after = vi.fn()
355
+ const config = await buildBetterAuthConfig(
356
+ makeAuthConfig({
357
+ session: { expiresIn: 604800, updateAge: 86400 },
358
+ betterAuthOptions: {
359
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
360
+ databaseHooks: { user: { create: { after } } } as any,
361
+ },
362
+ }),
363
+ )
364
+
365
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
366
+ expect((config as any).databaseHooks.user.create.after).toBe(after)
367
+ // The stack's own session config survives — passthrough added a sibling
368
+ // top-level key, it didn't replace the whole options object.
369
+ expect(config.session?.expiresIn).toBe(604800)
370
+ })
371
+
372
+ it('merges a nested session option without clobbering the stack-set session keys', async () => {
373
+ const config = await buildBetterAuthConfig(
374
+ makeAuthConfig({
375
+ session: { expiresIn: 604800, updateAge: 3600 },
376
+ betterAuthOptions: {
377
+ session: { cookieCache: { enabled: true, maxAge: 300 } },
378
+ },
379
+ }),
380
+ )
381
+
382
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
383
+ expect((config.session as any).cookieCache).toEqual({ enabled: true, maxAge: 300 })
384
+ expect(config.session?.expiresIn).toBe(604800)
385
+ expect(config.session?.updateAge).toBe(3600)
386
+ })
387
+
388
+ it('lets the passthrough win on a genuine key collision', async () => {
389
+ const config = await buildBetterAuthConfig(
390
+ makeAuthConfig({
391
+ session: { expiresIn: 604800, updateAge: 3600 },
392
+ betterAuthOptions: {
393
+ session: { expiresIn: 999 },
394
+ },
395
+ }),
396
+ )
397
+
398
+ expect(config.session?.expiresIn).toBe(999)
399
+ })
400
+
401
+ it('replaces an array outright instead of concatenating (e.g. trustedOrigins)', async () => {
402
+ process.env.BETTER_AUTH_TRUSTED_ORIGINS = 'https://env-origin.com'
403
+ try {
404
+ const config = await buildBetterAuthConfig(
405
+ makeAuthConfig({
406
+ betterAuthOptions: { trustedOrigins: ['https://config-origin.com'] },
407
+ }),
408
+ )
409
+
410
+ expect(config.trustedOrigins).toEqual(['https://config-origin.com'])
411
+ } finally {
412
+ delete process.env.BETTER_AUTH_TRUSTED_ORIGINS
413
+ }
414
+ })
415
+
416
+ it('rejects betterAuthOptions.database', async () => {
417
+ await expect(
418
+ buildBetterAuthOptions(
419
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
420
+ makeOpensaasConfig(makeAuthConfig({ betterAuthOptions: { database: {} as any } })),
421
+ makeContext(),
422
+ ),
423
+ ).rejects.toThrow(/betterAuthOptions\.database/)
424
+ })
425
+
426
+ it('rejects betterAuthOptions.plugins', async () => {
427
+ await expect(
428
+ buildBetterAuthOptions(
429
+ makeOpensaasConfig(makeAuthConfig({ betterAuthOptions: { plugins: [] } })),
430
+ makeContext(),
431
+ ),
432
+ ).rejects.toThrow(/betterAuthOptions\.plugins/)
433
+ })
434
+
435
+ it.each(['user', 'session', 'account', 'verification'] as const)(
436
+ 'rejects betterAuthOptions.%s.additionalFields',
437
+ async (model) => {
438
+ await expect(
439
+ buildBetterAuthOptions(
440
+ makeOpensaasConfig(
441
+ makeAuthConfig({
442
+ betterAuthOptions: {
443
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
444
+ [model]: { additionalFields: { foo: { type: 'string' } } } as any,
445
+ },
446
+ }),
447
+ ),
448
+ makeContext(),
449
+ ),
450
+ ).rejects.toThrow(new RegExp(`betterAuthOptions\\.${model}\\.additionalFields`))
451
+ },
452
+ )
453
+
454
+ it('does not reject additionalFields nested under an unrelated model key', async () => {
455
+ // A plain object at `user` with no `additionalFields` key must not trip the guard.
456
+ const config = await buildBetterAuthConfig(
457
+ makeAuthConfig({ betterAuthOptions: { user: { modelName: 'CustomUser' } } }),
458
+ )
459
+
460
+ expect(config.user).toMatchObject({ modelName: 'CustomUser' })
461
+ })
462
+ })
463
+
464
+ describe('buildBetterAuthOptions / createAuth parity', () => {
465
+ beforeEach(() => {
466
+ betterAuthMock.mockClear()
467
+ })
468
+
469
+ it('createAuth constructs betterAuth with exactly what buildBetterAuthOptions returns', async () => {
470
+ const authConfig = makeAuthConfig({
471
+ betterAuthOptions: { baseURL: 'https://example.com' },
472
+ })
473
+ const opensaasConfig = makeOpensaasConfig(authConfig)
474
+ const context = makeContext()
475
+
476
+ const built = await buildBetterAuthOptions(opensaasConfig, context)
477
+
478
+ const auth = createAuth(opensaasConfig, context)
479
+ await auth.api.getSession({})
480
+
481
+ expect(betterAuthMock).toHaveBeenCalledTimes(1)
482
+ expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
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
+ })
586
+ })
587
+
588
+ describe('getSessionFromAuth', () => {
589
+ it('passes the caller-supplied headers to auth.api.getSession', async () => {
590
+ const headers = new Headers({ cookie: 'session=abc' })
591
+ const getSession = vi.fn(async () => ({ user: { id: 'user-1', email: 'a@b.com' } }))
592
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
593
+
594
+ const result = await getSessionFromAuth(auth, ['userId', 'email'], headers)
595
+
596
+ expect(getSession).toHaveBeenCalledWith({ headers })
597
+ expect(result).toEqual({ userId: 'user-1', email: 'a@b.com' })
598
+ })
599
+
600
+ it('returns null when there is no session', async () => {
601
+ const getSession = vi.fn(async () => null)
602
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
603
+
604
+ const result = await getSessionFromAuth(auth, ['userId'], new Headers())
605
+
606
+ expect(result).toBeNull()
607
+ })
608
+
609
+ it('propagates an error thrown by the underlying session lookup, distinguishable from no session', async () => {
610
+ const getSession = vi.fn(async () => {
611
+ throw new Error('boom')
612
+ })
613
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
614
+
615
+ await expect(getSessionFromAuth(auth, ['userId'], new Headers())).rejects.toThrow('boom')
616
+ })
617
+
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
+ })
722
+ })
723
+ })