@opensaas/stack-auth 0.36.0 → 0.37.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 (38) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +109 -0
  3. package/CLAUDE.md +104 -7
  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 +126 -24
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/server/index.d.ts +29 -3
  22. package/dist/server/index.d.ts.map +1 -1
  23. package/dist/server/index.js +193 -66
  24. package/dist/server/index.js.map +1 -1
  25. package/package.json +3 -3
  26. package/src/config/adopt-better-auth-tables.ts +70 -1
  27. package/src/config/derive-auth-lists.ts +37 -38
  28. package/src/config/index.ts +47 -12
  29. package/src/config/plugin.ts +39 -27
  30. package/src/config/types.ts +127 -21
  31. package/src/server/index.ts +244 -80
  32. package/tests/adopt-better-auth-tables.test.ts +99 -0
  33. package/tests/config.test.ts +66 -8
  34. package/tests/derive-auth-lists.test.ts +79 -5
  35. package/tests/generated-fk-shape.test.ts +65 -0
  36. package/tests/plugin-derived-keys.test.ts +48 -0
  37. package/tests/server.test.ts +517 -0
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -79,3 +79,68 @@ describe('generated auth schema — Session/Account user FK mirrors better-auth
79
79
  expect(widget).toContain('@@index([ownerId])')
80
80
  })
81
81
  })
82
+
83
+ describe('generated auth schema — required columns mirror better-auth (issue #863)', () => {
84
+ it('emits a required (non-nullable) userId FK and user relation on Session and Account', async () => {
85
+ const schema = await generateSchema({
86
+ db: { provider: 'sqlite' },
87
+ plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
88
+ lists: {},
89
+ })
90
+
91
+ for (const model of ['Session', 'Account']) {
92
+ const block = modelBlock(schema, model)
93
+
94
+ expect(block).toMatch(/userId\s+String\s/)
95
+ expect(block).not.toMatch(/userId\s+String\?/)
96
+ expect(block).toMatch(/user\s+User\s+@relation/)
97
+ expect(block).not.toMatch(/user\s+User\?/)
98
+ }
99
+ })
100
+
101
+ it('emits a required (non-nullable) expiresAt on Session and Verification', async () => {
102
+ const schema = await generateSchema({
103
+ db: { provider: 'sqlite' },
104
+ plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
105
+ lists: {},
106
+ })
107
+
108
+ for (const model of ['Session', 'Verification']) {
109
+ const block = modelBlock(schema, model)
110
+
111
+ expect(block).toMatch(/expiresAt\s+DateTime\s/)
112
+ expect(block).not.toMatch(/expiresAt\s+DateTime\?/)
113
+ }
114
+ })
115
+ })
116
+
117
+ describe('generated auth schema — tableName independent of modelName (issue #862)', () => {
118
+ it('emits a prefixed model name with a @@map to a better-auth default lowercase table', async () => {
119
+ const schema = await generateSchema({
120
+ db: { provider: 'postgresql' },
121
+ plugins: [
122
+ authPlugin({
123
+ user: { modelName: 'AuthUser', tableName: 'user' },
124
+ session: { modelName: 'AuthSession', tableName: 'session' },
125
+ account: { modelName: 'AuthAccount', tableName: 'account' },
126
+ verification: { modelName: 'AuthVerification', tableName: 'verification' },
127
+ emailAndPassword: { enabled: true },
128
+ }),
129
+ ],
130
+ lists: {},
131
+ })
132
+
133
+ for (const [model, table] of [
134
+ ['AuthUser', 'user'],
135
+ ['AuthSession', 'session'],
136
+ ['AuthAccount', 'account'],
137
+ ['AuthVerification', 'verification'],
138
+ ]) {
139
+ const block = modelBlock(schema, model)
140
+ // The generated model keeps the prefixed name but maps to the live
141
+ // lowercase table — no DROP/CREATE rename against a real better-auth
142
+ // install using its own default table names.
143
+ expect(block).toContain(`@@map("${table}")`)
144
+ }
145
+ })
146
+ })
@@ -161,6 +161,54 @@ describe('authPlugin - add-vs-extend with derived keys', () => {
161
161
  expect(appUser.access?.operation?.update?.({} as any)).toBe(false)
162
162
  expect(appUser.access?.operation?.update).toBe(hostUserUpdate)
163
163
  })
164
+
165
+ it('preserves the derived Auth list db (map/schema/timestamps) and access when a better-auth plugin extends a base model (#861)', async () => {
166
+ // A better-auth provider plugin (e.g. the `anonymous` plugin) that ships a
167
+ // schema extension for the base `user` model — the exact shape adoptBetterAuthTables()
168
+ // + betterAuthPlugins produces in the field. Before the #861 fix, the
169
+ // plugin's schema loop ran BEFORE the derived Auth lists were added, so it
170
+ // pre-empted `AuthUser` with a bare `list({ fields })` (no `db`, no
171
+ // `access`) — and the real derived list then lost to the field-only
172
+ // `extendList` merge, silently dropping `@@map`/`@@schema`/timestamps/access.
173
+ const anonymousBetterAuthPlugin = {
174
+ id: 'anonymous',
175
+ schema: {
176
+ user: {
177
+ fields: {
178
+ isAnonymous: { type: 'boolean', required: false },
179
+ },
180
+ },
181
+ },
182
+ }
183
+
184
+ const userQuery = vi.fn(() => true)
185
+ const result = await config({
186
+ db: { provider: 'postgresql' },
187
+ plugins: [
188
+ authPlugin({
189
+ schema: 'auth',
190
+ user: { modelName: 'AuthUser' },
191
+ session: { modelName: 'AuthSession' },
192
+ account: { modelName: 'AuthAccount' },
193
+ verification: { modelName: 'AuthVerification' },
194
+ betterAuthPlugins: [anonymousBetterAuthPlugin],
195
+ access: { user: { operation: { query: userQuery } } },
196
+ }),
197
+ ],
198
+ lists: {},
199
+ })
200
+
201
+ const authUser = result.lists.AuthUser
202
+
203
+ // The plugin's schema extension is merged in...
204
+ expect(authUser.fields).toHaveProperty('isAnonymous')
205
+ // ...without displacing the derived list's own db config...
206
+ expect(authUser.db?.map).toBe('AuthUser')
207
+ expect(authUser.db?.schema).toBe('auth')
208
+ expect(authUser.db?.timestamps).toBe(true)
209
+ // ...or its access config.
210
+ expect(authUser.access?.operation?.query).toBe(userQuery)
211
+ })
164
212
  })
165
213
 
166
214
  describe('authPlugin - runtime user-key resolution', () => {
@@ -0,0 +1,517 @@
1
+ import { describe, it, expect, vi, beforeEach } 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
+
486
+ describe('getSessionFromAuth', () => {
487
+ it('passes the caller-supplied headers to auth.api.getSession', async () => {
488
+ const headers = new Headers({ cookie: 'session=abc' })
489
+ const getSession = vi.fn(async () => ({ user: { id: 'user-1', email: 'a@b.com' } }))
490
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
491
+
492
+ const result = await getSessionFromAuth(auth, ['userId', 'email'], headers)
493
+
494
+ expect(getSession).toHaveBeenCalledWith({ headers })
495
+ expect(result).toEqual({ userId: 'user-1', email: 'a@b.com' })
496
+ })
497
+
498
+ it('returns null when there is no session', async () => {
499
+ const getSession = vi.fn(async () => null)
500
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
501
+
502
+ const result = await getSessionFromAuth(auth, ['userId'], new Headers())
503
+
504
+ expect(result).toBeNull()
505
+ })
506
+
507
+ it('returns null when auth.api.getSession throws', async () => {
508
+ const getSession = vi.fn(async () => {
509
+ throw new Error('boom')
510
+ })
511
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
512
+
513
+ const result = await getSessionFromAuth(auth, ['userId'], new Headers())
514
+
515
+ expect(result).toBeNull()
516
+ })
517
+ })