@opensaas/stack-auth 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +146 -0
  3. package/CLAUDE.md +153 -9
  4. package/README.md +18 -7
  5. package/dist/config/adopt-better-auth-tables.d.ts +47 -0
  6. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  7. package/dist/config/adopt-better-auth-tables.js +29 -1
  8. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  9. package/dist/config/derive-auth-lists.d.ts +7 -5
  10. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  11. package/dist/config/derive-auth-lists.js +33 -34
  12. package/dist/config/derive-auth-lists.js.map +1 -1
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +41 -11
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/plugin.d.ts.map +1 -1
  17. package/dist/config/plugin.js +39 -27
  18. package/dist/config/plugin.js.map +1 -1
  19. package/dist/config/types.d.ts +143 -28
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/server/build-better-auth-options.test.d.ts +2 -0
  22. package/dist/server/build-better-auth-options.test.d.ts.map +1 -0
  23. package/dist/server/build-better-auth-options.test.js +29 -0
  24. package/dist/server/build-better-auth-options.test.js.map +1 -0
  25. package/dist/server/index.d.ts +112 -8
  26. package/dist/server/index.d.ts.map +1 -1
  27. package/dist/server/index.js +284 -96
  28. package/dist/server/index.js.map +1 -1
  29. package/dist/server/schema-converter.d.ts +3 -3
  30. package/dist/server/schema-converter.d.ts.map +1 -1
  31. package/package.json +5 -5
  32. package/src/config/adopt-better-auth-tables.ts +70 -1
  33. package/src/config/derive-auth-lists.ts +37 -38
  34. package/src/config/index.ts +47 -12
  35. package/src/config/plugin.ts +40 -28
  36. package/src/config/types.ts +144 -27
  37. package/src/server/build-better-auth-options.test.ts +59 -0
  38. package/src/server/index.ts +470 -106
  39. package/src/server/schema-converter.ts +3 -3
  40. package/tests/adopt-better-auth-tables.test.ts +99 -0
  41. package/tests/config.test.ts +66 -8
  42. package/tests/derive-auth-lists.test.ts +79 -5
  43. package/tests/generated-fk-shape.test.ts +65 -0
  44. package/tests/plugin-derived-keys.test.ts +48 -0
  45. package/tests/server.test.ts +723 -0
  46. package/tsconfig.tsbuildinfo +1 -1
  47. package/vitest.config.ts +7 -1
@@ -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 produces the four
7
- * OpenSaaS Auth lists (user/session/account/verification) with:
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`) when the key differs from the
11
- * default better-auth model name
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
- * When the developer renames the model (e.g. `modelName: 'AuthUser'`), we also
75
- * pin the physical table name to that model name via `@@map("AuthUser")` so the
76
- * generated list adopts the developer's live table exactly. When a `schema` is
77
- * configured (plugin-level or per-model), the list is placed in that Postgres
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 `modelName`/`schema` overrides we emit only `timestamps: true`,
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
- model: NormalizedAuthModelConfig,
85
- defaultModelName: string,
86
- ): { timestamps: true; map?: string; schema?: string } {
87
- const map = model.modelName !== defaultModelName ? model.modelName : undefined
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
- ...(map !== undefined ? { map } : {}),
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` — and
114
- * `onDelete: Cascade`, so a generated Auth schema diffs clean against a live
115
- * better-auth database on both dimensions instead of showing a spurious index
116
- * drop and a referential-action change (issue #679).
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, DEFAULT_MODEL_NAMES.user),
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({ db: fieldDb('expiresAt', f) }),
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, DEFAULT_MODEL_NAMES.session),
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, DEFAULT_MODEL_NAMES.account),
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({ db: fieldDb('expiresAt', f) }),
252
+ expiresAt: timestamp({
253
+ db: { isNullable: false, ...fieldDb('expiresAt', f) },
254
+ }),
256
255
  },
257
- db: listDb(model, DEFAULT_MODEL_NAMES.verification),
256
+ db: listDb(model),
258
257
  access,
259
258
  })
260
259
  }
@@ -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: config?.modelName || defaultModelName,
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 ?? true,
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
 
@@ -62,34 +62,13 @@ export function authPlugin(config: AuthConfig): Plugin {
62
62
  verification: normalized.models.verification.modelName,
63
63
  }
64
64
 
65
- // Extract additional lists from Better Auth plugins
66
- for (const plugin of normalized.betterAuthPlugins) {
67
- if (plugin && typeof plugin === 'object' && 'schema' in plugin) {
68
- // Plugin has schema property - convert to OpenSaaS lists
69
- const pluginSchema = plugin.schema
70
- const pluginLists = convertBetterAuthSchema(pluginSchema, baseModelKeys)
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)
@@ -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
- * Update session expiration on each request
81
- * @default true
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?: boolean
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 (and Prisma model name) and the table `@@map`.
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
- * This determines what data is available in access control functions
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth plugin types are not exposed, must use any
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. `schema`
373
- * carries the resolved Postgres schema for the model (per-model override, else
374
- * the plugin-level schema, else `undefined` for the default `public` schema).
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth plugin types are not exposed, must use any
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
+ })