@opensaas/stack-auth 0.35.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 +111 -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
@@ -34,6 +34,240 @@ function toBetterAuthModelOptions(
34
34
  return Object.keys(options).length > 0 ? options : undefined
35
35
  }
36
36
 
37
+ const MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH = [
38
+ 'user',
39
+ 'session',
40
+ 'account',
41
+ 'verification',
42
+ ] as const
43
+
44
+ /**
45
+ * Reject `betterAuthOptions` keys that already have a dedicated, non-passthrough
46
+ * seam — accepting them here would create two unranked ways to set the same
47
+ * thing, or (for `additionalFields`) silently diverge from the generated
48
+ * Prisma schema. See the `betterAuthOptions` doc comment on `AuthConfig`.
49
+ */
50
+ function assertNoUnsupportedPassthroughKeys(betterAuthOptions: Record<string, unknown>): void {
51
+ if ('database' in betterAuthOptions) {
52
+ throw new Error(
53
+ '[@opensaas/stack-auth] `betterAuthOptions.database` is not supported — the stack ' +
54
+ 'derives the database adapter from your `db` config and the running context. ' +
55
+ 'Configure the database through `db` in `opensaas.config.ts` instead.',
56
+ )
57
+ }
58
+
59
+ if ('plugins' in betterAuthOptions) {
60
+ throw new Error(
61
+ '[@opensaas/stack-auth] `betterAuthOptions.plugins` is not supported — better-auth ' +
62
+ 'plugins are added through `authPlugin({ betterAuthPlugins: [...] })`, which the stack ' +
63
+ 'appends `nextCookies()` after. Use `betterAuthPlugins` instead.',
64
+ )
65
+ }
66
+
67
+ for (const model of MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH) {
68
+ const modelOptions = betterAuthOptions[model]
69
+ if (
70
+ modelOptions &&
71
+ typeof modelOptions === 'object' &&
72
+ !Array.isArray(modelOptions) &&
73
+ 'additionalFields' in modelOptions
74
+ ) {
75
+ throw new Error(
76
+ `[@opensaas/stack-auth] \`betterAuthOptions.${model}.additionalFields\` is not ` +
77
+ 'supported — it adds columns that would not be reflected in the generated Prisma ' +
78
+ 'schema. Add fields to the derived list instead: ' +
79
+ (model === 'user'
80
+ ? '`extendUserList`, or declare the list yourself in your own `lists` config.'
81
+ : 'declare the derived list yourself in your own `lists` config (the auth plugin ' +
82
+ 'merges in field additions for the models it derives).'),
83
+ )
84
+ }
85
+ }
86
+ }
87
+
88
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
89
+ return (
90
+ typeof value === 'object' &&
91
+ value !== null &&
92
+ !Array.isArray(value) &&
93
+ Object.getPrototypeOf(value) === Object.prototype
94
+ )
95
+ }
96
+
97
+ /**
98
+ * Deep-merge `overrides` onto `base`, recursing into plain-object values so a
99
+ * nested addition (one database hook, one session sub-option) merges
100
+ * alongside sibling keys the stack already set there rather than replacing
101
+ * the whole branch. Arrays and any other value type replace outright.
102
+ * `overrides` wins on every key collision.
103
+ */
104
+ function mergeBetterAuthOptions(
105
+ base: Record<string, unknown>,
106
+ overrides: Record<string, unknown>,
107
+ ): Record<string, unknown> {
108
+ const result: Record<string, unknown> = { ...base }
109
+ for (const [key, value] of Object.entries(overrides)) {
110
+ const baseValue = result[key]
111
+ result[key] =
112
+ isPlainObject(baseValue) && isPlainObject(value)
113
+ ? mergeBetterAuthOptions(baseValue, value)
114
+ : value
115
+ }
116
+ return result
117
+ }
118
+
119
+ /**
120
+ * Build the `BetterAuthOptions` a better-auth instance for this OpenSaas
121
+ * config should be constructed with — the same options `createAuth()` uses
122
+ * internally, available standalone for an app that still needs to hand-wire
123
+ * its own `betterAuth()` instance (e.g. a third-party contract that requires
124
+ * a resolved instance at module-init time). Keeps the auth plugin
125
+ * authoritative for everything it models; the app's additions on top become
126
+ * an explicit, reviewable diff instead of a parallel, hand-duplicated config.
127
+ *
128
+ * @example
129
+ * ```typescript
130
+ * import { betterAuth } from 'better-auth'
131
+ * import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
132
+ *
133
+ * export const auth = betterAuth({
134
+ * ...(await buildBetterAuthOptions(config, context)),
135
+ * databaseHooks: { user: { create: { after: syncDomainUser } } },
136
+ * })
137
+ * ```
138
+ */
139
+ export async function buildBetterAuthOptions(
140
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
141
+ context: AccessContext | Promise<AccessContext>,
142
+ ): Promise<BetterAuthOptions> {
143
+ const resolvedConfig = await Promise.resolve(opensaasConfig)
144
+ const resolvedContext = await Promise.resolve(context)
145
+
146
+ // Extract auth config from plugin data
147
+ const authConfig = resolvedConfig._pluginData?.auth as NormalizedAuthConfig | undefined
148
+
149
+ if (!authConfig) {
150
+ throw new Error(
151
+ 'Auth config not found. Make sure to use authPlugin() in your opensaas.config.ts',
152
+ )
153
+ }
154
+
155
+ // `requireConfirmation` has no better-auth equivalent — it's a UI-only
156
+ // concern the pre-built forms already take as their own
157
+ // `requirePasswordConfirmation` prop. Warn rather than silently drop it,
158
+ // since setting it here looks like it should do something.
159
+ if (
160
+ authConfig.emailAndPassword.enabled &&
161
+ authConfig.emailAndPassword.requireConfirmation !== true
162
+ ) {
163
+ console.warn(
164
+ '[@opensaas/stack-auth] `emailAndPassword.requireConfirmation` has no effect here — ' +
165
+ 'createAuth() has no better-auth option to forward it to. Pass ' +
166
+ '`requirePasswordConfirmation` directly to <SignUpForm> / <ResetPasswordForm> instead.',
167
+ )
168
+ }
169
+
170
+ // `passwordReset` is wired through better-auth's `emailAndPassword` config
171
+ // (there's no password to reset without a password-based account), so it
172
+ // silently has no effect if email/password auth itself isn't enabled.
173
+ if (authConfig.passwordReset.enabled && !authConfig.emailAndPassword.enabled) {
174
+ console.warn(
175
+ '[@opensaas/stack-auth] `passwordReset.enabled` has no effect here — ' +
176
+ '`emailAndPassword.enabled` is false, so there is no password-based account to reset.',
177
+ )
178
+ }
179
+
180
+ assertNoUnsupportedPassthroughKeys(authConfig.betterAuthOptions as Record<string, unknown>)
181
+
182
+ // Build better-auth configuration
183
+ const betterAuthConfig: BetterAuthOptions = {
184
+ database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
185
+
186
+ // Mirror the per-model config (modelName + field column maps) back to
187
+ // better-auth so the running auth instance reads/writes the same
188
+ // tables/columns the OpenSaaS Auth lists were derived from.
189
+ user: toBetterAuthModelOptions(authConfig.models.user),
190
+ session: {
191
+ ...toBetterAuthModelOptions(authConfig.models.session),
192
+ expiresIn: authConfig.session.expiresIn || 604800,
193
+ // better-auth treats `updateAge: 0` as "refresh on every request", not
194
+ // "never refresh" — disabling refresh entirely requires its separate
195
+ // `disableSessionRefresh` flag regardless of `updateAge`.
196
+ ...(authConfig.session.updateAge === false
197
+ ? { disableSessionRefresh: true }
198
+ : { updateAge: authConfig.session.updateAge }),
199
+ },
200
+ account: toBetterAuthModelOptions(authConfig.models.account),
201
+ verification: toBetterAuthModelOptions(authConfig.models.verification),
202
+
203
+ // Enable email and password if configured
204
+ emailAndPassword: authConfig.emailAndPassword.enabled
205
+ ? {
206
+ enabled: true,
207
+ requireEmailVerification: authConfig.emailVerification.enabled,
208
+ minPasswordLength: authConfig.emailAndPassword.minPasswordLength,
209
+ ...(authConfig.passwordReset.enabled
210
+ ? {
211
+ sendResetPassword: authConfig.emailAndPassword.sendResetPassword,
212
+ resetPasswordTokenExpiresIn: authConfig.passwordReset.tokenExpiration,
213
+ }
214
+ : {}),
215
+ }
216
+ : undefined,
217
+
218
+ // Email verification (independent of emailAndPassword — also covers
219
+ // e.g. a social-provider account whose email isn't yet verified)
220
+ emailVerification: authConfig.emailVerification.enabled
221
+ ? {
222
+ sendVerificationEmail: authConfig.emailVerification.sendVerificationEmail,
223
+ sendOnSignUp: authConfig.emailVerification.sendOnSignUp,
224
+ expiresIn: authConfig.emailVerification.tokenExpiration,
225
+ }
226
+ : undefined,
227
+
228
+ // Trust host (required for production)
229
+ trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
230
+
231
+ // Social providers
232
+ socialProviders: Object.entries(authConfig.socialProviders)
233
+ .filter(([_, config]) => config?.enabled !== false)
234
+ .reduce(
235
+ (acc, [provider, config]) => {
236
+ if (config) {
237
+ acc[provider] = {
238
+ clientId: config.clientId,
239
+ clientSecret: config.clientSecret,
240
+ }
241
+ }
242
+ return acc
243
+ },
244
+ {} as Record<string, { clientId: string; clientSecret: string }>,
245
+ ),
246
+
247
+ // Rate limiting configuration
248
+ rateLimit: authConfig.rateLimit
249
+ ? {
250
+ enabled: authConfig.rateLimit.enabled,
251
+ window: authConfig.rateLimit.window,
252
+ max: authConfig.rateLimit.max,
253
+ }
254
+ : undefined,
255
+
256
+ // Pass through any additional Better Auth plugins, then append
257
+ // nextCookies LAST so it can write the Set-Cookie headers produced by
258
+ // any auth.api.* call made inside a Next.js server action into Next's
259
+ // cookie store. This is what makes the server-action auth forms (which
260
+ // call auth.api.signInEmail/signUpEmail/etc. server-side) actually
261
+ // persist a session. It must be the final plugin in the array.
262
+ plugins: [...(authConfig.betterAuthPlugins || []), nextCookies()],
263
+ }
264
+
265
+ return mergeBetterAuthOptions(
266
+ betterAuthConfig as unknown as Record<string, unknown>,
267
+ authConfig.betterAuthOptions as Record<string, unknown>,
268
+ ) as BetterAuthOptions
269
+ }
270
+
37
271
  /**
38
272
  * Create a better-auth instance from OpenSaas config
39
273
  * This should be called once at app startup
@@ -65,81 +299,7 @@ export function createAuth(
65
299
 
66
300
  if (!authPromise) {
67
301
  authPromise = (async () => {
68
- const resolvedConfig = await configPromise
69
- const resolvedContext = await contextPromise
70
-
71
- // Extract auth config from plugin data
72
- const authConfig = resolvedConfig._pluginData?.auth as NormalizedAuthConfig | undefined
73
-
74
- if (!authConfig) {
75
- throw new Error(
76
- 'Auth config not found. Make sure to use authPlugin() in your opensaas.config.ts',
77
- )
78
- }
79
-
80
- // Build better-auth configuration
81
- const betterAuthConfig: BetterAuthOptions = {
82
- database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
83
-
84
- // Mirror the per-model config (modelName + field column maps) back to
85
- // better-auth so the running auth instance reads/writes the same
86
- // tables/columns the OpenSaaS Auth lists were derived from.
87
- user: toBetterAuthModelOptions(authConfig.models.user),
88
- session: {
89
- ...toBetterAuthModelOptions(authConfig.models.session),
90
- expiresIn: authConfig.session.expiresIn || 604800,
91
- updateAge: authConfig.session.updateAge
92
- ? (authConfig.session.expiresIn || 604800) / 10
93
- : 0,
94
- },
95
- account: toBetterAuthModelOptions(authConfig.models.account),
96
- verification: toBetterAuthModelOptions(authConfig.models.verification),
97
-
98
- // Enable email and password if configured
99
- emailAndPassword: authConfig.emailAndPassword.enabled
100
- ? {
101
- enabled: true,
102
- requireEmailVerification: authConfig.emailVerification.enabled,
103
- }
104
- : undefined,
105
-
106
- // Trust host (required for production)
107
- trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
108
-
109
- // Social providers
110
- socialProviders: Object.entries(authConfig.socialProviders)
111
- .filter(([_, config]) => config?.enabled !== false)
112
- .reduce(
113
- (acc, [provider, config]) => {
114
- if (config) {
115
- acc[provider] = {
116
- clientId: config.clientId,
117
- clientSecret: config.clientSecret,
118
- }
119
- }
120
- return acc
121
- },
122
- {} as Record<string, { clientId: string; clientSecret: string }>,
123
- ),
124
-
125
- // Rate limiting configuration
126
- rateLimit: authConfig.rateLimit
127
- ? {
128
- enabled: authConfig.rateLimit.enabled,
129
- window: authConfig.rateLimit.window,
130
- max: authConfig.rateLimit.max,
131
- }
132
- : undefined,
133
-
134
- // Pass through any additional Better Auth plugins, then append
135
- // nextCookies LAST so it can write the Set-Cookie headers produced by
136
- // any auth.api.* call made inside a Next.js server action into Next's
137
- // cookie store. This is what makes the server-action auth forms (which
138
- // call auth.api.signInEmail/signUpEmail/etc. server-side) actually
139
- // persist a session. It must be the final plugin in the array.
140
- plugins: [...(authConfig.betterAuthPlugins || []), nextCookies()],
141
- }
142
-
302
+ const betterAuthConfig = await buildBetterAuthOptions(configPromise, contextPromise)
143
303
  authInstance = betterAuth(betterAuthConfig)
144
304
  return authInstance
145
305
  })()
@@ -195,17 +355,21 @@ export function createAuth(
195
355
  }
196
356
 
197
357
  /**
198
- * Get session from better-auth and transform it to OpenSaas session format
199
- * This is used internally by the generated context
358
+ * Get session from better-auth and transform it to OpenSaas session format.
359
+ *
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.
200
365
  */
201
366
  export async function getSessionFromAuth(
202
367
  auth: ReturnType<typeof betterAuth>,
203
368
  sessionFields: string[],
369
+ headers: Headers,
204
370
  ) {
205
371
  try {
206
- const session = await auth.api.getSession({
207
- headers: new Headers(),
208
- })
372
+ const session = await auth.api.getSession({ headers })
209
373
 
210
374
  if (!session?.user) {
211
375
  return null
@@ -76,6 +76,105 @@ describe('adoptBetterAuthTables - recipe defaults', () => {
76
76
  })
77
77
  })
78
78
 
79
+ describe('adoptBetterAuthTables - table names (issue #862)', () => {
80
+ it('sets no tableName by default (prefixed modelName also pins the table, as before)', () => {
81
+ const fragment = adoptBetterAuthTables()
82
+
83
+ expect(fragment.user).toEqual({ modelName: 'AuthUser' })
84
+ expect(fragment.session).toEqual({ modelName: 'AuthSession' })
85
+ })
86
+
87
+ it('useBetterAuthTableNames sets every model tableName to the better-auth default lowercase name', () => {
88
+ const fragment = adoptBetterAuthTables({ useBetterAuthTableNames: true })
89
+
90
+ expect(fragment.user).toEqual({ modelName: 'AuthUser', tableName: 'user' })
91
+ expect(fragment.session).toEqual({ modelName: 'AuthSession', tableName: 'session' })
92
+ expect(fragment.account).toEqual({ modelName: 'AuthAccount', tableName: 'account' })
93
+ expect(fragment.verification).toEqual({
94
+ modelName: 'AuthVerification',
95
+ tableName: 'verification',
96
+ })
97
+ })
98
+
99
+ it('tableNames sets explicit per-model table names', () => {
100
+ const fragment = adoptBetterAuthTables({
101
+ tableNames: { user: 'users', session: 'user_sessions' },
102
+ })
103
+
104
+ expect(fragment.user).toEqual({ modelName: 'AuthUser', tableName: 'users' })
105
+ expect(fragment.session).toEqual({ modelName: 'AuthSession', tableName: 'user_sessions' })
106
+ // Models without an explicit tableName entry stay unset.
107
+ expect(fragment.account).toEqual({ modelName: 'AuthAccount' })
108
+ })
109
+
110
+ it('tableNames takes precedence over useBetterAuthTableNames for the same model', () => {
111
+ const fragment = adoptBetterAuthTables({
112
+ useBetterAuthTableNames: true,
113
+ tableNames: { user: 'custom_user_table' },
114
+ })
115
+
116
+ expect(fragment.user?.tableName).toBe('custom_user_table')
117
+ // Other models still fall back to the better-auth default.
118
+ expect(fragment.session?.tableName).toBe('session')
119
+ })
120
+
121
+ it('combines tableName with a field column map on the same model', () => {
122
+ const fragment = adoptBetterAuthTables({
123
+ useBetterAuthTableNames: true,
124
+ fields: { user: { name: 'full_name' } },
125
+ })
126
+
127
+ expect(fragment.user).toEqual({
128
+ modelName: 'AuthUser',
129
+ tableName: 'user',
130
+ fields: { name: 'full_name' },
131
+ })
132
+ })
133
+ })
134
+
135
+ describe('adoptBetterAuthTables - clean-diff adoption with better-auth default table names', () => {
136
+ it('produces Auth lists that @@map to the live lowercase tables under prefixed list keys', async () => {
137
+ const result = await generationConfig({
138
+ db: { provider: 'postgresql' },
139
+ plugins: [
140
+ authPlugin({
141
+ ...adoptBetterAuthTables({ useBetterAuthTableNames: true }),
142
+ emailAndPassword: { enabled: true },
143
+ }),
144
+ ],
145
+ lists: {
146
+ User: list({
147
+ fields: { subjectId: text({ validation: { isRequired: true } }) },
148
+ }),
149
+ },
150
+ })
151
+
152
+ // List keys stay prefixed (no collision with the app's own User)...
153
+ expect(result.lists).toHaveProperty('AuthUser')
154
+ // ...but the physical table is better-auth's own default lowercase name.
155
+ expect(result.lists.AuthUser.db).toEqual({ timestamps: true, map: 'user', schema: 'auth' })
156
+ expect(result.lists.AuthSession.db).toEqual({
157
+ timestamps: true,
158
+ map: 'session',
159
+ schema: 'auth',
160
+ })
161
+ expect(result.lists.AuthAccount.db).toEqual({
162
+ timestamps: true,
163
+ map: 'account',
164
+ schema: 'auth',
165
+ })
166
+ expect(result.lists.AuthVerification.db).toEqual({
167
+ timestamps: true,
168
+ map: 'verification',
169
+ schema: 'auth',
170
+ })
171
+
172
+ // The app's own domain User is untouched.
173
+ expect(result.lists.User.fields).toHaveProperty('subjectId')
174
+ expect(result.lists.User.fields).not.toHaveProperty('email')
175
+ })
176
+ })
177
+
79
178
  describe('adoptBetterAuthTables - composes with authPlugin', () => {
80
179
  it('spreads into authPlugin alongside the rest of the auth config', async () => {
81
180
  const result = await config({
@@ -14,9 +14,19 @@ describe('normalizeAuthConfig', () => {
14
14
  expect(result.emailVerification.enabled).toBe(false)
15
15
  expect(result.passwordReset.enabled).toBe(false)
16
16
  expect(result.session.expiresIn).toBe(604800) // 7 days
17
+ expect(result.session.updateAge).toBe(86400) // 1 day, matching better-auth's own default
17
18
  expect(result.sessionFields).toEqual(['userId', 'email', 'name'])
18
19
  expect(result.socialProviders).toEqual({})
19
20
  expect(result.betterAuthPlugins).toEqual([])
21
+ expect(result.betterAuthOptions).toEqual({})
22
+ })
23
+
24
+ it('should pass through betterAuthOptions unchanged', () => {
25
+ const result = normalizeAuthConfig({
26
+ betterAuthOptions: { baseURL: 'https://example.com' },
27
+ })
28
+
29
+ expect(result.betterAuthOptions).toEqual({ baseURL: 'https://example.com' })
20
30
  })
21
31
 
22
32
  it('should normalize email and password config', () => {
@@ -81,6 +91,14 @@ describe('normalizeAuthConfig', () => {
81
91
  expect(result.session.updateAge).toBe(false)
82
92
  })
83
93
 
94
+ it('should normalize a custom numeric session.updateAge', () => {
95
+ const result = normalizeAuthConfig({
96
+ session: { updateAge: 3600 },
97
+ })
98
+
99
+ expect(result.session.updateAge).toBe(3600)
100
+ })
101
+
84
102
  it('should normalize custom session fields', () => {
85
103
  const result = normalizeAuthConfig({
86
104
  sessionFields: ['userId', 'email', 'role'],
@@ -120,22 +138,24 @@ describe('normalizeAuthConfig', () => {
120
138
  expect(result.extendUserList.fields).toHaveProperty('role')
121
139
  })
122
140
 
123
- it('should include custom sendEmail function', () => {
124
- const mockSendEmail = async () => {}
141
+ it('should include custom sendResetPassword and sendVerificationEmail functions', () => {
142
+ const mockSendResetPassword = async () => {}
143
+ const mockSendVerificationEmail = async () => {}
125
144
 
126
145
  const result = normalizeAuthConfig({
127
- sendEmail: mockSendEmail,
146
+ emailAndPassword: { enabled: true, sendResetPassword: mockSendResetPassword },
147
+ emailVerification: { enabled: true, sendVerificationEmail: mockSendVerificationEmail },
128
148
  })
129
149
 
130
- expect(result.sendEmail).toBe(mockSendEmail)
150
+ expect(result.emailAndPassword.sendResetPassword).toBe(mockSendResetPassword)
151
+ expect(result.emailVerification.sendVerificationEmail).toBe(mockSendVerificationEmail)
131
152
  })
132
153
 
133
- it('should provide default sendEmail that logs to console', () => {
154
+ it('should provide default sendResetPassword and sendVerificationEmail that log to console', () => {
134
155
  const result = normalizeAuthConfig({})
135
156
 
136
- expect(typeof result.sendEmail).toBe('function')
137
- // Default sendEmail should be a function that accepts email params
138
- expect(result.sendEmail.length).toBe(1)
157
+ expect(typeof result.emailAndPassword.sendResetPassword).toBe('function')
158
+ expect(typeof result.emailVerification.sendVerificationEmail).toBe('function')
139
159
  })
140
160
 
141
161
  it('should include betterAuthPlugins', () => {
@@ -165,6 +185,44 @@ describe('normalizeAuthConfig', () => {
165
185
  expect(result.access.user).toBe(userAccess)
166
186
  expect(result.access.session).toBe(sessionAccess)
167
187
  })
188
+
189
+ describe('models.tableName (issue #862)', () => {
190
+ it('defaults tableName to undefined for unrenamed models', () => {
191
+ const result = normalizeAuthConfig({})
192
+
193
+ expect(result.models.user.tableName).toBeUndefined()
194
+ expect(result.models.session.tableName).toBeUndefined()
195
+ expect(result.models.account.tableName).toBeUndefined()
196
+ expect(result.models.verification.tableName).toBeUndefined()
197
+ })
198
+
199
+ it('defaults tableName to the renamed modelName, matching pre-#862 behaviour', () => {
200
+ const result = normalizeAuthConfig({
201
+ user: { modelName: 'AuthUser' },
202
+ })
203
+
204
+ expect(result.models.user.modelName).toBe('AuthUser')
205
+ expect(result.models.user.tableName).toBe('AuthUser')
206
+ })
207
+
208
+ it('resolves an explicit tableName independent of modelName', () => {
209
+ const result = normalizeAuthConfig({
210
+ user: { modelName: 'AuthUser', tableName: 'user' },
211
+ })
212
+
213
+ expect(result.models.user.modelName).toBe('AuthUser')
214
+ expect(result.models.user.tableName).toBe('user')
215
+ })
216
+
217
+ it('honours an explicit tableName even when modelName is left at its default', () => {
218
+ const result = normalizeAuthConfig({
219
+ session: { tableName: 'sessions' },
220
+ })
221
+
222
+ expect(result.models.session.modelName).toBe('Session')
223
+ expect(result.models.session.tableName).toBe('sessions')
224
+ })
225
+ })
168
226
  })
169
227
 
170
228
  describe('authPlugin', () => {
@@ -44,6 +44,13 @@ describe('deriveAuthLists - default behaviour (no overrides)', () => {
44
44
  expect(lists.User.fields.accounts.ref).toBe('Account.user')
45
45
  })
46
46
 
47
+ it('marks Session/Verification expiresAt as DB-required, matching better-auth (issue #863)', () => {
48
+ const { lists } = deriveAuthLists(defaultModels)
49
+
50
+ expect(lists.Session.fields.expiresAt.db?.isNullable).toBe(false)
51
+ expect(lists.Verification.fields.expiresAt.db?.isNullable).toBe(false)
52
+ })
53
+
47
54
  it('emits no table @@map and no scalar @map for default keys', () => {
48
55
  const { lists } = deriveAuthLists(defaultModels)
49
56
 
@@ -108,6 +115,13 @@ describe('deriveAuthLists - user FK shape mirrors better-auth (issue #679)', ()
108
115
  }
109
116
  })
110
117
 
118
+ it('marks the user FK non-nullable, matching better-auth (issue #863)', () => {
119
+ const { lists } = deriveAuthLists(defaultModels)
120
+
121
+ expect(lists.Session.fields.user.db?.isNullable).toBe(false)
122
+ expect(lists.Account.fields.user.db?.isNullable).toBe(false)
123
+ })
124
+
111
125
  it('keeps the cascade extendPrismaSchema alongside a userId column override', () => {
112
126
  const models: NormalizedAuthModels = {
113
127
  user: { modelName: 'User', fields: {} },
@@ -126,11 +140,16 @@ describe('deriveAuthLists - user FK shape mirrors better-auth (issue #679)', ()
126
140
  })
127
141
 
128
142
  describe('deriveAuthLists - custom modelName overrides', () => {
143
+ // `deriveAuthLists` is the pure derivation step: it consumes an already-
144
+ // resolved `tableName` rather than re-deriving one from `modelName`. That
145
+ // default derivation (tableName follows modelName when it differs from the
146
+ // better-auth default) lives in `normalizeModelConfig` (config/index.ts) —
147
+ // mirror its output here since these fixtures bypass normalization.
129
148
  const customModels: NormalizedAuthModels = {
130
- user: { modelName: 'AuthUser', fields: {} },
131
- session: { modelName: 'AuthSession', fields: {} },
132
- account: { modelName: 'AuthAccount', fields: {} },
133
- verification: { modelName: 'AuthVerification', fields: {} },
149
+ user: { modelName: 'AuthUser', tableName: 'AuthUser', fields: {} },
150
+ session: { modelName: 'AuthSession', tableName: 'AuthSession', fields: {} },
151
+ account: { modelName: 'AuthAccount', tableName: 'AuthAccount', fields: {} },
152
+ verification: { modelName: 'AuthVerification', tableName: 'AuthVerification', fields: {} },
134
153
  }
135
154
 
136
155
  it('derives list keys from modelName', () => {
@@ -179,6 +198,61 @@ describe('deriveAuthLists - custom modelName overrides', () => {
179
198
  })
180
199
  })
181
200
 
201
+ describe('deriveAuthLists - tableName independent of modelName (issue #862)', () => {
202
+ it('emits @@map from tableName even though modelName is unchanged', () => {
203
+ const models: NormalizedAuthModels = {
204
+ user: { modelName: 'User', tableName: 'users', fields: {} },
205
+ session: { modelName: 'Session', fields: {} },
206
+ account: { modelName: 'Account', fields: {} },
207
+ verification: { modelName: 'Verification', fields: {} },
208
+ }
209
+
210
+ const { keys, lists } = deriveAuthLists(models)
211
+
212
+ // The list key follows modelName, unaffected by tableName.
213
+ expect(keys.user).toBe('User')
214
+ expect(lists.User.db?.map).toBe('users')
215
+ })
216
+
217
+ it('adopts a better-auth default lowercase table under a prefixed list key', () => {
218
+ // The shape issue #862 exists for: a prefixed list key (to avoid
219
+ // colliding with the app's own domain User) whose live table is still
220
+ // better-auth's own default lowercase name.
221
+ const models: NormalizedAuthModels = {
222
+ user: { modelName: 'AuthUser', tableName: 'user', fields: {} },
223
+ session: { modelName: 'AuthSession', tableName: 'session', fields: {} },
224
+ account: { modelName: 'AuthAccount', tableName: 'account', fields: {} },
225
+ verification: { modelName: 'AuthVerification', tableName: 'verification', fields: {} },
226
+ }
227
+
228
+ const { keys, lists } = deriveAuthLists(models)
229
+
230
+ expect(keys).toEqual({
231
+ user: 'AuthUser',
232
+ session: 'AuthSession',
233
+ account: 'AuthAccount',
234
+ verification: 'AuthVerification',
235
+ })
236
+ expect(lists.AuthUser.db?.map).toBe('user')
237
+ expect(lists.AuthSession.db?.map).toBe('session')
238
+ expect(lists.AuthAccount.db?.map).toBe('account')
239
+ expect(lists.AuthVerification.db?.map).toBe('verification')
240
+ })
241
+
242
+ it('emits no @@map when tableName is unset, even with a renamed modelName', () => {
243
+ const models: NormalizedAuthModels = {
244
+ user: { modelName: 'AuthUser', fields: {} },
245
+ session: { modelName: 'Session', fields: {} },
246
+ account: { modelName: 'Account', fields: {} },
247
+ verification: { modelName: 'Verification', fields: {} },
248
+ }
249
+
250
+ const { lists } = deriveAuthLists(models)
251
+
252
+ expect(lists.AuthUser.db?.map).toBeUndefined()
253
+ })
254
+ })
255
+
182
256
  describe('deriveAuthLists - custom field column maps', () => {
183
257
  const models: NormalizedAuthModels = {
184
258
  user: { modelName: 'AuthUser', fields: { name: 'full_name', emailVerified: 'is_verified' } },
@@ -229,7 +303,7 @@ describe('deriveAuthLists - schema placement', () => {
229
303
 
230
304
  it('carries both @@map and @@schema for renamed + relocated lists', () => {
231
305
  const models: NormalizedAuthModels = {
232
- user: { modelName: 'AuthUser', fields: {}, schema: 'auth' },
306
+ user: { modelName: 'AuthUser', tableName: 'AuthUser', fields: {}, schema: 'auth' },
233
307
  session: { modelName: 'AuthSession', fields: {}, schema: 'auth' },
234
308
  account: { modelName: 'AuthAccount', fields: {}, schema: 'auth' },
235
309
  verification: { modelName: 'AuthVerification', fields: {}, schema: 'auth' },