@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +109 -0
- package/CLAUDE.md +104 -7
- package/README.md +18 -7
- package/dist/config/adopt-better-auth-tables.d.ts +47 -0
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +29 -1
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +7 -5
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +33 -34
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +41 -11
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +39 -27
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +126 -24
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server/index.d.ts +29 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +193 -66
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -3
- package/src/config/adopt-better-auth-tables.ts +70 -1
- package/src/config/derive-auth-lists.ts +37 -38
- package/src/config/index.ts +47 -12
- package/src/config/plugin.ts +39 -27
- package/src/config/types.ts +127 -21
- package/src/server/index.ts +244 -80
- package/tests/adopt-better-auth-tables.test.ts +99 -0
- package/tests/config.test.ts +66 -8
- package/tests/derive-auth-lists.test.ts +79 -5
- package/tests/generated-fk-shape.test.ts +65 -0
- package/tests/plugin-derived-keys.test.ts +48 -0
- package/tests/server.test.ts +517 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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({
|
package/tests/config.test.ts
CHANGED
|
@@ -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
|
|
124
|
-
const
|
|
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
|
-
|
|
146
|
+
emailAndPassword: { enabled: true, sendResetPassword: mockSendResetPassword },
|
|
147
|
+
emailVerification: { enabled: true, sendVerificationEmail: mockSendVerificationEmail },
|
|
128
148
|
})
|
|
129
149
|
|
|
130
|
-
expect(result.
|
|
150
|
+
expect(result.emailAndPassword.sendResetPassword).toBe(mockSendResetPassword)
|
|
151
|
+
expect(result.emailVerification.sendVerificationEmail).toBe(mockSendVerificationEmail)
|
|
131
152
|
})
|
|
132
153
|
|
|
133
|
-
it('should provide default
|
|
154
|
+
it('should provide default sendResetPassword and sendVerificationEmail that log to console', () => {
|
|
134
155
|
const result = normalizeAuthConfig({})
|
|
135
156
|
|
|
136
|
-
expect(typeof result.
|
|
137
|
-
|
|
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' },
|