@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +146 -0
- package/CLAUDE.md +153 -9
- 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 +143 -28
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server/build-better-auth-options.test.d.ts +2 -0
- package/dist/server/build-better-auth-options.test.d.ts.map +1 -0
- package/dist/server/build-better-auth-options.test.js +29 -0
- package/dist/server/build-better-auth-options.test.js.map +1 -0
- package/dist/server/index.d.ts +112 -8
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +284 -96
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema-converter.d.ts +3 -3
- package/dist/server/schema-converter.d.ts.map +1 -1
- package/package.json +5 -5
- 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 +40 -28
- package/src/config/types.ts +144 -27
- package/src/server/build-better-auth-options.test.ts +59 -0
- package/src/server/index.ts +470 -106
- package/src/server/schema-converter.ts +3 -3
- 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 +723 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +7 -1
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* Pure `better-auth config → Auth lists` derivation.
|
|
3
3
|
*
|
|
4
4
|
* This module is intentionally free of side effects and plugin/runtime
|
|
5
|
-
* concerns: given the resolved better-auth model config (per-model `modelName
|
|
6
|
-
* and `fields` column maps) plus any custom User fields, it
|
|
7
|
-
* OpenSaaS Auth lists (user/session/account/verification)
|
|
5
|
+
* concerns: given the resolved better-auth model config (per-model `modelName`,
|
|
6
|
+
* `tableName`, and `fields` column maps) plus any custom User fields, it
|
|
7
|
+
* produces the four OpenSaaS Auth lists (user/session/account/verification)
|
|
8
|
+
* with:
|
|
8
9
|
*
|
|
9
10
|
* - list keys taken from each model's `modelName`
|
|
10
|
-
* - a table `@@map` (list-level `db.map`)
|
|
11
|
-
*
|
|
11
|
+
* - a table `@@map` (list-level `db.map`) taken from each model's resolved
|
|
12
|
+
* `tableName` — independent of `modelName`, so a renamed list key can still
|
|
13
|
+
* adopt a differently-named live table
|
|
12
14
|
* - field-level `@map` (`db.map`) for any better-auth field → column override
|
|
13
15
|
* - relationship refs between the auth lists wired to the *derived* keys
|
|
14
16
|
* (e.g. `Session.user → AuthUser.sessions`)
|
|
@@ -31,17 +33,6 @@ import type { RelationshipField } from '@opensaas/stack-core/fields'
|
|
|
31
33
|
import type { ExtendUserListConfig } from '../lists/index.js'
|
|
32
34
|
import type { AuthAccessConfig, NormalizedAuthModelConfig, NormalizedAuthModels } from './types.js'
|
|
33
35
|
|
|
34
|
-
/**
|
|
35
|
-
* Default better-auth model names — used to decide whether a `@@map` is needed
|
|
36
|
-
* (only when the configured `modelName` differs from the default).
|
|
37
|
-
*/
|
|
38
|
-
const DEFAULT_MODEL_NAMES = {
|
|
39
|
-
user: 'User',
|
|
40
|
-
session: 'Session',
|
|
41
|
-
account: 'Account',
|
|
42
|
-
verification: 'Verification',
|
|
43
|
-
} as const
|
|
44
|
-
|
|
45
36
|
/**
|
|
46
37
|
* The derived Auth list set together with the keys each list was placed under.
|
|
47
38
|
* Keys are surfaced separately so callers (plugin add-vs-extend logic, runtime
|
|
@@ -71,24 +62,24 @@ export type DerivedAuthLists = {
|
|
|
71
62
|
* Auth list must opt back in so the generated models keep those columns and
|
|
72
63
|
* better-auth keeps working.
|
|
73
64
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* schema via `@@schema(...)`.
|
|
65
|
+
* The physical table name (`@@map`) comes from the model's resolved
|
|
66
|
+
* `tableName` — independent of the list key/`modelName` — so a renamed list
|
|
67
|
+
* key can still adopt a differently-named live table (e.g. better-auth's own
|
|
68
|
+
* default lowercase table names). When a `schema` is configured (plugin-level
|
|
69
|
+
* or per-model), the list is placed in that Postgres schema via `@@schema(...)`.
|
|
79
70
|
*
|
|
80
|
-
* With no `
|
|
71
|
+
* With no `tableName`/`schema` overrides we emit only `timestamps: true`,
|
|
81
72
|
* leaving the default `User`/`Session`/... table/schema output unchanged.
|
|
82
73
|
*/
|
|
83
|
-
function listDb(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
74
|
+
function listDb(model: NormalizedAuthModelConfig): {
|
|
75
|
+
timestamps: true
|
|
76
|
+
map?: string
|
|
77
|
+
schema?: string
|
|
78
|
+
} {
|
|
88
79
|
const schema = model.schema
|
|
89
80
|
return {
|
|
90
81
|
timestamps: true,
|
|
91
|
-
...(
|
|
82
|
+
...(model.tableName !== undefined ? { map: model.tableName } : {}),
|
|
92
83
|
...(schema !== undefined ? { schema } : {}),
|
|
93
84
|
}
|
|
94
85
|
}
|
|
@@ -110,14 +101,18 @@ function fieldDb(fieldName: string, fields: Record<string, string>): { map: stri
|
|
|
110
101
|
* Build the `db` config for a `user` relationship (`Session.user` /
|
|
111
102
|
* `Account.user`), honouring a `userId` column override from the better-auth
|
|
112
103
|
* `fields` map and mirroring better-auth's own FK shape: no separate FK index
|
|
113
|
-
* — the index is applied at the field level via `isIndexed: false` —
|
|
114
|
-
* `onDelete: Cascade`,
|
|
115
|
-
* better-auth
|
|
116
|
-
*
|
|
104
|
+
* — the index is applied at the field level via `isIndexed: false` —
|
|
105
|
+
* `onDelete: Cascade`, and a required (non-nullable) foreign key, since
|
|
106
|
+
* better-auth's adapter always writes a `userId` on every session/account row
|
|
107
|
+
* it creates. This means a generated Auth schema diffs clean against a live
|
|
108
|
+
* better-auth database on all three dimensions instead of showing a spurious
|
|
109
|
+
* index drop, a referential-action change, and a `DROP NOT NULL` (issues #679,
|
|
110
|
+
* #863).
|
|
117
111
|
*/
|
|
118
112
|
function userRelationshipDb(fields: Record<string, string>): NonNullable<RelationshipField['db']> {
|
|
119
113
|
const column = fields.userId
|
|
120
114
|
return {
|
|
115
|
+
isNullable: false,
|
|
121
116
|
...(column ? { foreignKey: { map: column } } : {}),
|
|
122
117
|
extendPrismaSchema: ({ fkLine, relationLine }) => ({
|
|
123
118
|
fkLine,
|
|
@@ -161,7 +156,7 @@ function createUserList(
|
|
|
161
156
|
// Custom fields from user config
|
|
162
157
|
...(userConfig.fields || {}),
|
|
163
158
|
},
|
|
164
|
-
db: listDb(model
|
|
159
|
+
db: listDb(model),
|
|
165
160
|
access: userConfig.access || access,
|
|
166
161
|
hooks: userConfig.hooks,
|
|
167
162
|
})
|
|
@@ -187,7 +182,9 @@ function createSessionList(
|
|
|
187
182
|
isIndexed: 'unique',
|
|
188
183
|
db: fieldDb('token', f),
|
|
189
184
|
}),
|
|
190
|
-
expiresAt: timestamp({
|
|
185
|
+
expiresAt: timestamp({
|
|
186
|
+
db: { isNullable: false, ...fieldDb('expiresAt', f) },
|
|
187
|
+
}),
|
|
191
188
|
ipAddress: text({ db: fieldDb('ipAddress', f) }),
|
|
192
189
|
userAgent: text({ db: fieldDb('userAgent', f) }),
|
|
193
190
|
user: relationship({
|
|
@@ -196,7 +193,7 @@ function createSessionList(
|
|
|
196
193
|
db: userRelationshipDb(f),
|
|
197
194
|
}),
|
|
198
195
|
},
|
|
199
|
-
db: listDb(model
|
|
196
|
+
db: listDb(model),
|
|
200
197
|
access,
|
|
201
198
|
})
|
|
202
199
|
}
|
|
@@ -231,7 +228,7 @@ function createAccountList(
|
|
|
231
228
|
idToken: text({ db: fieldDb('idToken', f) }),
|
|
232
229
|
password: text({ db: fieldDb('password', f) }),
|
|
233
230
|
},
|
|
234
|
-
db: listDb(model
|
|
231
|
+
db: listDb(model),
|
|
235
232
|
access,
|
|
236
233
|
})
|
|
237
234
|
}
|
|
@@ -252,9 +249,11 @@ function createVerificationList(
|
|
|
252
249
|
fields: {
|
|
253
250
|
identifier: text({ validation: { isRequired: true }, db: fieldDb('identifier', f) }),
|
|
254
251
|
value: text({ validation: { isRequired: true }, db: fieldDb('value', f) }),
|
|
255
|
-
expiresAt: timestamp({
|
|
252
|
+
expiresAt: timestamp({
|
|
253
|
+
db: { isNullable: false, ...fieldDb('expiresAt', f) },
|
|
254
|
+
}),
|
|
256
255
|
},
|
|
257
|
-
db: listDb(model
|
|
256
|
+
db: listDb(model),
|
|
258
257
|
access,
|
|
259
258
|
})
|
|
260
259
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
EmailPasswordConfig,
|
|
8
8
|
EmailVerificationConfig,
|
|
9
9
|
PasswordResetConfig,
|
|
10
|
+
SendAuthEmail,
|
|
10
11
|
} from './types.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -26,14 +27,24 @@ const DEFAULT_MODEL_NAMES = {
|
|
|
26
27
|
* falling back to the better-auth default model name and an empty column map.
|
|
27
28
|
* The model's Postgres schema is the per-model `schema` override when present,
|
|
28
29
|
* otherwise the plugin-level `schema` default (or `undefined` for `public`).
|
|
30
|
+
*
|
|
31
|
+
* `tableName` defaults to today's behaviour when not explicitly set: it
|
|
32
|
+
* follows `modelName` when that differs from the better-auth default (so a
|
|
33
|
+
* renamed list still pins its table via `@@map`), otherwise it stays unset.
|
|
34
|
+
* An explicit `tableName` is independent of `modelName` — it lets a renamed
|
|
35
|
+
* list key adopt a differently-named live table (e.g. better-auth's own
|
|
36
|
+
* default lowercase table names).
|
|
29
37
|
*/
|
|
30
38
|
function normalizeModelConfig(
|
|
31
39
|
config: AuthModelConfig | undefined,
|
|
32
40
|
defaultModelName: string,
|
|
33
41
|
defaultSchema: string | undefined,
|
|
34
42
|
): NormalizedAuthModelConfig {
|
|
43
|
+
const modelName = config?.modelName || defaultModelName
|
|
44
|
+
const tableName = config?.tableName ?? (modelName !== defaultModelName ? modelName : undefined)
|
|
35
45
|
return {
|
|
36
|
-
modelName
|
|
46
|
+
modelName,
|
|
47
|
+
tableName,
|
|
37
48
|
fields: config?.fields || {},
|
|
38
49
|
schema: config?.schema ?? defaultSchema,
|
|
39
50
|
}
|
|
@@ -58,6 +69,21 @@ function normalizeAuthModels(config: AuthConfig): NormalizedAuthModels {
|
|
|
58
69
|
}
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Default `sendResetPassword`/`sendVerificationEmail` — logs to console
|
|
74
|
+
* instead of sending, matching the pre-existing "no sendEmail configured"
|
|
75
|
+
* behavior for apps that haven't wired a real email provider yet.
|
|
76
|
+
*/
|
|
77
|
+
function defaultSendAuthEmail(kind: 'password reset' | 'verification'): SendAuthEmail {
|
|
78
|
+
return async ({ user, url }) => {
|
|
79
|
+
console.log(
|
|
80
|
+
`[Auth] ${kind[0].toUpperCase()}${kind.slice(1)} email not sent (no callback configured):`,
|
|
81
|
+
)
|
|
82
|
+
console.log(`To: ${user.email}`)
|
|
83
|
+
console.log(`URL: ${url}`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
/**
|
|
62
88
|
* Normalize auth configuration with defaults
|
|
63
89
|
*/
|
|
@@ -69,8 +95,16 @@ export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
|
|
|
69
95
|
minPasswordLength: (config.emailAndPassword as EmailPasswordConfig).minPasswordLength ?? 8,
|
|
70
96
|
requireConfirmation:
|
|
71
97
|
(config.emailAndPassword as EmailPasswordConfig).requireConfirmation ?? true,
|
|
98
|
+
sendResetPassword:
|
|
99
|
+
(config.emailAndPassword as EmailPasswordConfig).sendResetPassword ??
|
|
100
|
+
defaultSendAuthEmail('password reset'),
|
|
101
|
+
}
|
|
102
|
+
: {
|
|
103
|
+
enabled: false as const,
|
|
104
|
+
minPasswordLength: 8,
|
|
105
|
+
requireConfirmation: true,
|
|
106
|
+
sendResetPassword: defaultSendAuthEmail('password reset'),
|
|
72
107
|
}
|
|
73
|
-
: { enabled: false as const, minPasswordLength: 8, requireConfirmation: true }
|
|
74
108
|
|
|
75
109
|
// Email verification defaults
|
|
76
110
|
const emailVerification = config.emailVerification?.enabled
|
|
@@ -79,8 +113,16 @@ export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
|
|
|
79
113
|
sendOnSignUp: (config.emailVerification as EmailVerificationConfig).sendOnSignUp ?? true,
|
|
80
114
|
tokenExpiration:
|
|
81
115
|
(config.emailVerification as EmailVerificationConfig).tokenExpiration ?? 86400,
|
|
116
|
+
sendVerificationEmail:
|
|
117
|
+
(config.emailVerification as EmailVerificationConfig).sendVerificationEmail ??
|
|
118
|
+
defaultSendAuthEmail('verification'),
|
|
119
|
+
}
|
|
120
|
+
: {
|
|
121
|
+
enabled: false as const,
|
|
122
|
+
sendOnSignUp: true,
|
|
123
|
+
tokenExpiration: 86400,
|
|
124
|
+
sendVerificationEmail: defaultSendAuthEmail('verification'),
|
|
82
125
|
}
|
|
83
|
-
: { enabled: false as const, sendOnSignUp: true, tokenExpiration: 86400 }
|
|
84
126
|
|
|
85
127
|
// Password reset defaults
|
|
86
128
|
const passwordReset = config.passwordReset?.enabled
|
|
@@ -93,7 +135,7 @@ export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
|
|
|
93
135
|
// Session defaults
|
|
94
136
|
const session = {
|
|
95
137
|
expiresIn: config.session?.expiresIn || 604800, // 7 days
|
|
96
|
-
updateAge: config.session?.updateAge ??
|
|
138
|
+
updateAge: config.session?.updateAge ?? 86400, // 1 day, matching better-auth's own default
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
// Session fields defaults
|
|
@@ -114,16 +156,9 @@ export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
|
|
|
114
156
|
sessionFields,
|
|
115
157
|
extendUserList: config.extendUserList || {},
|
|
116
158
|
access: config.access || {},
|
|
117
|
-
sendEmail:
|
|
118
|
-
config.sendEmail ||
|
|
119
|
-
(async ({ to, subject, html }) => {
|
|
120
|
-
console.log('[Auth] Email not sent (no sendEmail configured):')
|
|
121
|
-
console.log(`To: ${to}`)
|
|
122
|
-
console.log(`Subject: ${subject}`)
|
|
123
|
-
console.log(`Body: ${html}`)
|
|
124
|
-
}),
|
|
125
159
|
betterAuthPlugins: config.betterAuthPlugins || [],
|
|
126
160
|
rateLimit: config.rateLimit,
|
|
161
|
+
betterAuthOptions: config.betterAuthOptions || {},
|
|
127
162
|
}
|
|
128
163
|
}
|
|
129
164
|
|
package/src/config/plugin.ts
CHANGED
|
@@ -62,34 +62,13 @@ export function authPlugin(config: AuthConfig): Plugin {
|
|
|
62
62
|
verification: normalized.models.verification.modelName,
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// Add or extend lists from plugin
|
|
73
|
-
for (const [listName, listConfig] of Object.entries(pluginLists)) {
|
|
74
|
-
if (context.config.lists[listName]) {
|
|
75
|
-
// List already exists — merge fields/hooks/mcp in only. Access
|
|
76
|
-
// control belongs to whoever owns the list; per ADR-0013 an
|
|
77
|
-
// extension must never carry operation-level access for a
|
|
78
|
-
// pre-existing list (the plugin engine throws if it does).
|
|
79
|
-
context.extendList(listName, {
|
|
80
|
-
fields: listConfig.fields,
|
|
81
|
-
hooks: listConfig.hooks,
|
|
82
|
-
mcp: listConfig.mcp,
|
|
83
|
-
})
|
|
84
|
-
} else {
|
|
85
|
-
// List doesn't exist, add it
|
|
86
|
-
context.addList(listName, listConfig)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Add all auth lists.
|
|
65
|
+
// Add all auth lists FIRST, before any better-auth plugin schema
|
|
66
|
+
// extension is processed. This must happen before the betterAuthPlugins
|
|
67
|
+
// loop below so a base-model extension (e.g. a plugin's `schema: {
|
|
68
|
+
// user: { fields: … } }`) always finds the real derived list already
|
|
69
|
+
// registered under its key and takes the merge (`extendList`) path
|
|
70
|
+
// against it — instead of pre-empting that key with a bare
|
|
71
|
+
// `list({ fields })` that carries no `db`/`access` (see #861).
|
|
93
72
|
//
|
|
94
73
|
// The plugin only ever touches its OWN derived keys. When a developer
|
|
95
74
|
// renames the auth user model (e.g. user.modelName: 'AuthUser'), the
|
|
@@ -115,6 +94,39 @@ export function authPlugin(config: AuthConfig): Plugin {
|
|
|
115
94
|
}
|
|
116
95
|
}
|
|
117
96
|
|
|
97
|
+
// Extract additional lists from Better Auth plugins. Because the auth
|
|
98
|
+
// lists above are already registered, a schema extension of a base
|
|
99
|
+
// model (user/session/account/verification) always finds its resolved
|
|
100
|
+
// key occupied by the real derived list and merges via `extendList` —
|
|
101
|
+
// its `db`/`access` are preserved. Non-base plugin tables (e.g.
|
|
102
|
+
// `oauth_application`, `passkey`) still register as new lists via
|
|
103
|
+
// `addList`, same as before.
|
|
104
|
+
for (const plugin of normalized.betterAuthPlugins) {
|
|
105
|
+
if (plugin && typeof plugin === 'object' && plugin.schema) {
|
|
106
|
+
// Plugin has schema property - convert to OpenSaaS lists
|
|
107
|
+
const pluginSchema = plugin.schema
|
|
108
|
+
const pluginLists = convertBetterAuthSchema(pluginSchema, baseModelKeys)
|
|
109
|
+
|
|
110
|
+
// Add or extend lists from plugin
|
|
111
|
+
for (const [listName, listConfig] of Object.entries(pluginLists)) {
|
|
112
|
+
if (context.config.lists[listName]) {
|
|
113
|
+
// List already exists — merge fields/hooks/mcp in only. Access
|
|
114
|
+
// control belongs to whoever owns the list; per ADR-0013 an
|
|
115
|
+
// extension must never carry operation-level access for a
|
|
116
|
+
// pre-existing list (the plugin engine throws if it does).
|
|
117
|
+
context.extendList(listName, {
|
|
118
|
+
fields: listConfig.fields,
|
|
119
|
+
hooks: listConfig.hooks,
|
|
120
|
+
mcp: listConfig.mcp,
|
|
121
|
+
})
|
|
122
|
+
} else {
|
|
123
|
+
// List doesn't exist, add it
|
|
124
|
+
context.addList(listName, listConfig)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
// Store auth config for runtime access
|
|
119
131
|
// Access at runtime via: config._pluginData.auth
|
|
120
132
|
context.setPluginData<NormalizedAuthConfig>('auth', normalized)
|
package/src/config/types.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import type { ListConfig } from '@opensaas/stack-core'
|
|
2
|
+
import type { BetterAuthOptions, BetterAuthPlugin, User } from 'better-auth'
|
|
2
3
|
import type { ExtendUserListConfig } from '../lists/index.js'
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* better-auth's own callback shape for `sendVerificationEmail`/
|
|
7
|
+
* `sendResetPassword` — `data` is exactly what better-auth passes (no stack
|
|
8
|
+
* abstraction layered on top), and `request` is the raw request that
|
|
9
|
+
* triggered it.
|
|
10
|
+
*/
|
|
11
|
+
export type SendAuthEmail = (
|
|
12
|
+
data: { user: User; url: string; token: string },
|
|
13
|
+
request?: Request,
|
|
14
|
+
) => Promise<void>
|
|
15
|
+
|
|
4
16
|
/**
|
|
5
17
|
* OAuth provider configuration
|
|
6
18
|
*/
|
|
@@ -32,10 +44,36 @@ export type EmailPasswordConfig = {
|
|
|
32
44
|
*/
|
|
33
45
|
minPasswordLength?: number
|
|
34
46
|
/**
|
|
35
|
-
* Require password confirmation
|
|
47
|
+
* Require password confirmation (a second "confirm password" field).
|
|
48
|
+
*
|
|
49
|
+
* There is no better-auth server-side equivalent — this is purely a UI
|
|
50
|
+
* concern. `createAuth()` does not read it. Pass it directly to the
|
|
51
|
+
* pre-built forms instead: `<SignUpForm requirePasswordConfirmation={...} />`
|
|
52
|
+
* / `<ResetPasswordForm requirePasswordConfirmation={...} />` (both default
|
|
53
|
+
* to `true`). Setting it here has no effect and `createAuth()` warns if it
|
|
54
|
+
* is configured.
|
|
55
|
+
*
|
|
36
56
|
* @default true
|
|
37
57
|
*/
|
|
38
58
|
requireConfirmation?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Send a password reset email. Passed straight through to better-auth's own
|
|
61
|
+
* `emailAndPassword.sendResetPassword` — the stack does not wrap or
|
|
62
|
+
* reshape it. If not provided, reset emails are logged to console instead
|
|
63
|
+
* of sent.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* sendResetPassword: async ({ user, url }) => {
|
|
68
|
+
* await resend.emails.send({
|
|
69
|
+
* to: user.email,
|
|
70
|
+
* subject: 'Reset your password',
|
|
71
|
+
* html: `<a href="${url}">Reset your password</a>`,
|
|
72
|
+
* })
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
sendResetPassword?: SendAuthEmail
|
|
39
77
|
}
|
|
40
78
|
|
|
41
79
|
/**
|
|
@@ -53,6 +91,24 @@ export type EmailVerificationConfig = {
|
|
|
53
91
|
* @default 86400 (24 hours)
|
|
54
92
|
*/
|
|
55
93
|
tokenExpiration?: number
|
|
94
|
+
/**
|
|
95
|
+
* Send a verification email. Passed straight through to better-auth's own
|
|
96
|
+
* `emailVerification.sendVerificationEmail` — the stack does not wrap or
|
|
97
|
+
* reshape it. If not provided, verification emails are logged to console
|
|
98
|
+
* instead of sent.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```typescript
|
|
102
|
+
* sendVerificationEmail: async ({ user, url }) => {
|
|
103
|
+
* await resend.emails.send({
|
|
104
|
+
* to: user.email,
|
|
105
|
+
* subject: 'Verify your email',
|
|
106
|
+
* html: `<a href="${url}">Verify your email</a>`,
|
|
107
|
+
* })
|
|
108
|
+
* }
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
sendVerificationEmail?: SendAuthEmail
|
|
56
112
|
}
|
|
57
113
|
|
|
58
114
|
/**
|
|
@@ -77,10 +133,12 @@ export type SessionConfig = {
|
|
|
77
133
|
*/
|
|
78
134
|
expiresIn?: number
|
|
79
135
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
136
|
+
* How often the session should be refreshed, in seconds. Passed straight
|
|
137
|
+
* through to better-auth's own `session.updateAge`. Set `false` to disable
|
|
138
|
+
* refresh entirely (the session expiry is then fixed at creation time).
|
|
139
|
+
* @default 86400 (1 day, matching better-auth's own default)
|
|
82
140
|
*/
|
|
83
|
-
updateAge?:
|
|
141
|
+
updateAge?: number | false
|
|
84
142
|
}
|
|
85
143
|
|
|
86
144
|
/**
|
|
@@ -150,10 +208,30 @@ export type AuthAccessConfig = {
|
|
|
150
208
|
export type AuthModelConfig = {
|
|
151
209
|
/**
|
|
152
210
|
* The table/list name for this model.
|
|
153
|
-
* Becomes the OpenSaaS list key
|
|
211
|
+
* Becomes the OpenSaaS list key and Prisma model name.
|
|
154
212
|
* @default the default better-auth model name (e.g. 'User', 'Session')
|
|
155
213
|
*/
|
|
156
214
|
modelName?: string
|
|
215
|
+
/**
|
|
216
|
+
* The physical database table name for this model, independent of
|
|
217
|
+
* `modelName`. Generates a `@@map("...")` on the derived list.
|
|
218
|
+
*
|
|
219
|
+
* Lets a renamed list key (e.g. `modelName: 'AuthUser'`, to avoid colliding
|
|
220
|
+
* with an app's own domain `User`) still adopt a live table under a
|
|
221
|
+
* different name — most commonly better-auth's own default lowercase
|
|
222
|
+
* table names (`user`, `session`, `account`, `verification`).
|
|
223
|
+
*
|
|
224
|
+
* @default `modelName` when it differs from the better-auth default model
|
|
225
|
+
* name, otherwise unset (no `@@map`) — i.e. today's behaviour when this
|
|
226
|
+
* option is not set.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```typescript
|
|
230
|
+
* // List key AuthUser, but the live table is still called `user`.
|
|
231
|
+
* user: { modelName: 'AuthUser', tableName: 'user' }
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
tableName?: string
|
|
157
235
|
/**
|
|
158
236
|
* Map better-auth field names to database column names.
|
|
159
237
|
* Each entry generates a `@map("column")` on the derived field.
|
|
@@ -259,8 +337,21 @@ export type AuthConfig = {
|
|
|
259
337
|
schema?: string
|
|
260
338
|
|
|
261
339
|
/**
|
|
262
|
-
* Which fields to include in the session object
|
|
263
|
-
*
|
|
340
|
+
* Which fields to include in the session object passed to access control
|
|
341
|
+
* functions — a **flattened projection** of the resolved better-auth
|
|
342
|
+
* session, not the session's own shape. `getSessionFromAuth` (the
|
|
343
|
+
* implementation the scaffolded `getSession()` calls) resolves each name
|
|
344
|
+
* against a fixed precedence: a top-level key on the resolved session
|
|
345
|
+
* object, then the `user` object, then the `session` sub-object.
|
|
346
|
+
* `userId` is special-cased to the authenticated user's `id`.
|
|
347
|
+
*
|
|
348
|
+
* A `customSession` better-auth plugin fully replaces the resolved shape
|
|
349
|
+
* (it can nest fields anywhere, e.g. under its own custom key) —
|
|
350
|
+
* reconciling that shape against `sessionFields` is the application's job.
|
|
351
|
+
* A listed name that can't be resolved is omitted and warns once per
|
|
352
|
+
* field per process, naming what was checked, rather than silently
|
|
353
|
+
* becoming `undefined` in an access control function.
|
|
354
|
+
*
|
|
264
355
|
* @default ['userId', 'email', 'name']
|
|
265
356
|
*
|
|
266
357
|
* @example
|
|
@@ -303,19 +394,6 @@ export type AuthConfig = {
|
|
|
303
394
|
*/
|
|
304
395
|
access?: AuthAccessConfig
|
|
305
396
|
|
|
306
|
-
/**
|
|
307
|
-
* Custom email sending function for verification and password reset
|
|
308
|
-
* If not provided, emails will be logged to console
|
|
309
|
-
*
|
|
310
|
-
* @example
|
|
311
|
-
* ```typescript
|
|
312
|
-
* sendEmail: async ({ to, subject, html }) => {
|
|
313
|
-
* await resend.emails.send({ to, subject, html })
|
|
314
|
-
* }
|
|
315
|
-
* ```
|
|
316
|
-
*/
|
|
317
|
-
sendEmail?: (params: { to: string; subject: string; html: string }) => Promise<void>
|
|
318
|
-
|
|
319
397
|
/**
|
|
320
398
|
* Additional Better Auth plugins to enable
|
|
321
399
|
* Allows integrating any Better Auth plugin (MCP, 2FA, etc.)
|
|
@@ -329,8 +407,7 @@ export type AuthConfig = {
|
|
|
329
407
|
* ]
|
|
330
408
|
* ```
|
|
331
409
|
*/
|
|
332
|
-
|
|
333
|
-
betterAuthPlugins?: any[]
|
|
410
|
+
betterAuthPlugins?: BetterAuthPlugin[]
|
|
334
411
|
|
|
335
412
|
/**
|
|
336
413
|
* Rate limiting configuration
|
|
@@ -364,17 +441,58 @@ export type AuthConfig = {
|
|
|
364
441
|
*/
|
|
365
442
|
max?: number
|
|
366
443
|
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Escape hatch for better-auth options the stack doesn't model — typed as
|
|
447
|
+
* better-auth's own `BetterAuthOptions` so it stays in step with
|
|
448
|
+
* better-auth's surface without the stack re-declaring it. Deep-merged into
|
|
449
|
+
* the options `createAuth()` builds, applied LAST: a plain-object value at a
|
|
450
|
+
* given key merges recursively with whatever the stack already set there
|
|
451
|
+
* (so e.g. `session: { cookieCache: {...} }` adds alongside the stack's own
|
|
452
|
+
* `session.expiresIn`/`updateAge` rather than clobbering them); any other
|
|
453
|
+
* value (including arrays) replaces the stack's value outright. On a genuine
|
|
454
|
+
* key collision, this option wins.
|
|
455
|
+
*
|
|
456
|
+
* `database` and `plugins` are rejected — they're already the dedicated
|
|
457
|
+
* seams (the stack's `db` config, and `betterAuthPlugins` respectively) and
|
|
458
|
+
* accepting them here would create two unranked ways to set the same thing.
|
|
459
|
+
* So is `additionalFields` under `user`/`session`/`account`/`verification` —
|
|
460
|
+
* it has schema consequences (new columns) that a passthrough can't also
|
|
461
|
+
* apply to the generated Prisma schema; add fields to the derived list
|
|
462
|
+
* instead (`extendUserList` for the user model, or declare the list
|
|
463
|
+
* yourself for the others — see `packages/auth/CLAUDE.md`).
|
|
464
|
+
*
|
|
465
|
+
* The same options object is available standalone via
|
|
466
|
+
* `buildBetterAuthOptions()` (`@opensaas/stack-auth/server`) for apps that
|
|
467
|
+
* still need to hand-wire their own `betterAuth()` instance.
|
|
468
|
+
*
|
|
469
|
+
* @example
|
|
470
|
+
* ```typescript
|
|
471
|
+
* authPlugin({
|
|
472
|
+
* betterAuthOptions: {
|
|
473
|
+
* databaseHooks: { user: { create: { after: syncDomainUser } } },
|
|
474
|
+
* session: { cookieCache: { enabled: true, maxAge: 300 } },
|
|
475
|
+
* verification: { storeIdentifier: 'hashed' },
|
|
476
|
+
* baseURL: process.env.BETTER_AUTH_URL,
|
|
477
|
+
* },
|
|
478
|
+
* })
|
|
479
|
+
* ```
|
|
480
|
+
*/
|
|
481
|
+
betterAuthOptions?: Partial<BetterAuthOptions>
|
|
367
482
|
}
|
|
368
483
|
|
|
369
484
|
/**
|
|
370
485
|
* Resolved per-model auth configuration after normalization.
|
|
371
486
|
* Always carries a concrete `modelName` (the developer's override or the
|
|
372
|
-
* better-auth default) and a (possibly empty) `fields` column map. `
|
|
373
|
-
*
|
|
374
|
-
* the
|
|
487
|
+
* better-auth default) and a (possibly empty) `fields` column map. `tableName`
|
|
488
|
+
* is the resolved physical table name — `undefined` means no `@@map` is
|
|
489
|
+
* emitted (the list key doubles as the table name). `schema` carries the
|
|
490
|
+
* resolved Postgres schema for the model (per-model override, else the
|
|
491
|
+
* plugin-level schema, else `undefined` for the default `public` schema).
|
|
375
492
|
*/
|
|
376
493
|
export type NormalizedAuthModelConfig = {
|
|
377
494
|
modelName: string
|
|
495
|
+
tableName?: string
|
|
378
496
|
fields: Record<string, string>
|
|
379
497
|
schema?: string
|
|
380
498
|
}
|
|
@@ -422,8 +540,7 @@ export type NormalizedAuthConfig = Required<
|
|
|
422
540
|
* default (used to wire the datasource `schemas` array during generation).
|
|
423
541
|
*/
|
|
424
542
|
schema?: string
|
|
425
|
-
|
|
426
|
-
betterAuthPlugins: any[]
|
|
543
|
+
betterAuthPlugins: BetterAuthPlugin[]
|
|
427
544
|
rateLimit?: {
|
|
428
545
|
enabled: boolean
|
|
429
546
|
window?: number
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest'
|
|
2
|
+
import { betterAuth } from 'better-auth'
|
|
3
|
+
import type { BetterAuthOptions } from 'better-auth'
|
|
4
|
+
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
5
|
+
import { emailOTP, customSession } from 'better-auth/plugins'
|
|
6
|
+
import { buildBetterAuthOptions } from './index.js'
|
|
7
|
+
|
|
8
|
+
// The literal shape a `customSession()` callback replaces the session with —
|
|
9
|
+
// deliberately unlike better-auth's default `{ user, session }`, to prove the
|
|
10
|
+
// builder's return type carries a custom shape through to `api.getSession()`.
|
|
11
|
+
type AppSession = {
|
|
12
|
+
data: { allowAdminUI: boolean; subjectId: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type TestPlugins = [ReturnType<typeof emailOTP>, ReturnType<typeof customSession<AppSession>>]
|
|
16
|
+
|
|
17
|
+
// `ReturnType<typeof buildBetterAuthOptions>` on the overloaded export itself
|
|
18
|
+
// resolves against its last (generic) signature, not the call-site-selected
|
|
19
|
+
// one — wrapping each call shape in its own ordinary function and reading
|
|
20
|
+
// `ReturnType<typeof wrapper>` instead forces TS to resolve overloads exactly
|
|
21
|
+
// as a real call site would.
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
|
|
23
|
+
function callWithNoPlugins(
|
|
24
|
+
config: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
25
|
+
context: AccessContext | Promise<AccessContext>,
|
|
26
|
+
) {
|
|
27
|
+
return buildBetterAuthOptions(config, context)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
|
|
31
|
+
function callWithPlugins(
|
|
32
|
+
config: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
33
|
+
context: AccessContext | Promise<AccessContext>,
|
|
34
|
+
plugins: TestPlugins,
|
|
35
|
+
) {
|
|
36
|
+
return buildBetterAuthOptions(config, context, plugins)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type NoArgResult = Awaited<ReturnType<typeof callWithNoPlugins>>
|
|
40
|
+
type BuiltOptionsWithPlugins = Awaited<ReturnType<typeof callWithPlugins>>
|
|
41
|
+
type ConstructedAuth = ReturnType<typeof betterAuth<BuiltOptionsWithPlugins>>
|
|
42
|
+
|
|
43
|
+
describe('buildBetterAuthOptions plugin-tuple typing', () => {
|
|
44
|
+
it('back-compat: the no-argument call still returns the widened BetterAuthOptions', () => {
|
|
45
|
+
expectTypeOf<NoArgResult>().toEqualTypeOf<BetterAuthOptions>()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('preserves plugin-derived auth.api.* endpoints and a customSession shape', () => {
|
|
49
|
+
// emailOTP() endpoints exist on the constructed Auth's `api` — erased entirely
|
|
50
|
+
// when constructed from the widened `BetterAuthOptions` (see #876).
|
|
51
|
+
expectTypeOf<ConstructedAuth['api']['signInEmailOTP']>().not.toBeNever()
|
|
52
|
+
expectTypeOf<ConstructedAuth['api']['sendVerificationOTP']>().not.toBeNever()
|
|
53
|
+
expectTypeOf<ConstructedAuth['api']['checkVerificationOTP']>().not.toBeNever()
|
|
54
|
+
|
|
55
|
+
// customSession()'s replaced shape, not better-auth's default { user, session }.
|
|
56
|
+
type GetSessionReturn = Awaited<ReturnType<ConstructedAuth['api']['getSession']>>
|
|
57
|
+
expectTypeOf<GetSessionReturn>().toEqualTypeOf<AppSession | null>()
|
|
58
|
+
})
|
|
59
|
+
})
|