@opensaas/stack-auth 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +111 -0
  3. package/CLAUDE.md +104 -7
  4. package/README.md +18 -7
  5. package/dist/config/adopt-better-auth-tables.d.ts +47 -0
  6. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  7. package/dist/config/adopt-better-auth-tables.js +29 -1
  8. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  9. package/dist/config/derive-auth-lists.d.ts +7 -5
  10. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  11. package/dist/config/derive-auth-lists.js +33 -34
  12. package/dist/config/derive-auth-lists.js.map +1 -1
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +41 -11
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/plugin.d.ts.map +1 -1
  17. package/dist/config/plugin.js +39 -27
  18. package/dist/config/plugin.js.map +1 -1
  19. package/dist/config/types.d.ts +126 -24
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/server/index.d.ts +29 -3
  22. package/dist/server/index.d.ts.map +1 -1
  23. package/dist/server/index.js +193 -66
  24. package/dist/server/index.js.map +1 -1
  25. package/package.json +3 -3
  26. package/src/config/adopt-better-auth-tables.ts +70 -1
  27. package/src/config/derive-auth-lists.ts +37 -38
  28. package/src/config/index.ts +47 -12
  29. package/src/config/plugin.ts +39 -27
  30. package/src/config/types.ts +127 -21
  31. package/src/server/index.ts +244 -80
  32. package/tests/adopt-better-auth-tables.test.ts +99 -0
  33. package/tests/config.test.ts +66 -8
  34. package/tests/derive-auth-lists.test.ts +79 -5
  35. package/tests/generated-fk-shape.test.ts +65 -0
  36. package/tests/plugin-derived-keys.test.ts +48 -0
  37. package/tests/server.test.ts +517 -0
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -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,7 +62,45 @@ 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
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).
72
+ //
73
+ // The plugin only ever touches its OWN derived keys. When a developer
74
+ // renames the auth user model (e.g. user.modelName: 'AuthUser'), the
75
+ // derived key is 'AuthUser' and an app's separate 'User' list is left
76
+ // untouched — the plugin never extends/overwrites a list it didn't
77
+ // derive. Extending only kicks in when an existing list shares the
78
+ // derived key (e.g. the default 'User'), which is the intended
79
+ // "merge auth fields into my User" behaviour.
80
+ for (const [listName, listConfig] of Object.entries(authLists)) {
81
+ if (context.config.lists[listName]) {
82
+ // A list already exists under this derived key — merge auth fields
83
+ // in only. Access control belongs to whoever owns the list (the
84
+ // application declared it first), so the plugin never forwards its
85
+ // own access here — see ADR-0013.
86
+ context.extendList(listName, {
87
+ fields: listConfig.fields,
88
+ hooks: listConfig.hooks,
89
+ mcp: listConfig.mcp,
90
+ })
91
+ } else {
92
+ // Otherwise, add the auth list
93
+ context.addList(listName, listConfig)
94
+ }
95
+ }
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.
66
104
  for (const plugin of normalized.betterAuthPlugins) {
67
105
  if (plugin && typeof plugin === 'object' && 'schema' in plugin) {
68
106
  // Plugin has schema property - convert to OpenSaaS lists
@@ -89,32 +127,6 @@ export function authPlugin(config: AuthConfig): Plugin {
89
127
  }
90
128
  }
91
129
 
92
- // Add all auth lists.
93
- //
94
- // The plugin only ever touches its OWN derived keys. When a developer
95
- // renames the auth user model (e.g. user.modelName: 'AuthUser'), the
96
- // derived key is 'AuthUser' and an app's separate 'User' list is left
97
- // untouched — the plugin never extends/overwrites a list it didn't
98
- // derive. Extending only kicks in when an existing list shares the
99
- // derived key (e.g. the default 'User'), which is the intended
100
- // "merge auth fields into my User" behaviour.
101
- for (const [listName, listConfig] of Object.entries(authLists)) {
102
- if (context.config.lists[listName]) {
103
- // A list already exists under this derived key — merge auth fields
104
- // in only. Access control belongs to whoever owns the list (the
105
- // application declared it first), so the plugin never forwards its
106
- // own access here — see ADR-0013.
107
- context.extendList(listName, {
108
- fields: listConfig.fields,
109
- hooks: listConfig.hooks,
110
- mcp: listConfig.mcp,
111
- })
112
- } else {
113
- // Otherwise, add the auth list
114
- context.addList(listName, listConfig)
115
- }
116
- }
117
-
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, 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.
@@ -303,19 +381,6 @@ export type AuthConfig = {
303
381
  */
304
382
  access?: AuthAccessConfig
305
383
 
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
384
  /**
320
385
  * Additional Better Auth plugins to enable
321
386
  * Allows integrating any Better Auth plugin (MCP, 2FA, etc.)
@@ -364,17 +429,58 @@ export type AuthConfig = {
364
429
  */
365
430
  max?: number
366
431
  }
432
+
433
+ /**
434
+ * Escape hatch for better-auth options the stack doesn't model — typed as
435
+ * better-auth's own `BetterAuthOptions` so it stays in step with
436
+ * better-auth's surface without the stack re-declaring it. Deep-merged into
437
+ * the options `createAuth()` builds, applied LAST: a plain-object value at a
438
+ * given key merges recursively with whatever the stack already set there
439
+ * (so e.g. `session: { cookieCache: {...} }` adds alongside the stack's own
440
+ * `session.expiresIn`/`updateAge` rather than clobbering them); any other
441
+ * value (including arrays) replaces the stack's value outright. On a genuine
442
+ * key collision, this option wins.
443
+ *
444
+ * `database` and `plugins` are rejected — they're already the dedicated
445
+ * seams (the stack's `db` config, and `betterAuthPlugins` respectively) and
446
+ * accepting them here would create two unranked ways to set the same thing.
447
+ * So is `additionalFields` under `user`/`session`/`account`/`verification` —
448
+ * it has schema consequences (new columns) that a passthrough can't also
449
+ * apply to the generated Prisma schema; add fields to the derived list
450
+ * instead (`extendUserList` for the user model, or declare the list
451
+ * yourself for the others — see `packages/auth/CLAUDE.md`).
452
+ *
453
+ * The same options object is available standalone via
454
+ * `buildBetterAuthOptions()` (`@opensaas/stack-auth/server`) for apps that
455
+ * still need to hand-wire their own `betterAuth()` instance.
456
+ *
457
+ * @example
458
+ * ```typescript
459
+ * authPlugin({
460
+ * betterAuthOptions: {
461
+ * databaseHooks: { user: { create: { after: syncDomainUser } } },
462
+ * session: { cookieCache: { enabled: true, maxAge: 300 } },
463
+ * verification: { storeIdentifier: 'hashed' },
464
+ * baseURL: process.env.BETTER_AUTH_URL,
465
+ * },
466
+ * })
467
+ * ```
468
+ */
469
+ betterAuthOptions?: Partial<BetterAuthOptions>
367
470
  }
368
471
 
369
472
  /**
370
473
  * Resolved per-model auth configuration after normalization.
371
474
  * 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).
475
+ * better-auth default) and a (possibly empty) `fields` column map. `tableName`
476
+ * is the resolved physical table name `undefined` means no `@@map` is
477
+ * emitted (the list key doubles as the table name). `schema` carries the
478
+ * resolved Postgres schema for the model (per-model override, else the
479
+ * plugin-level schema, else `undefined` for the default `public` schema).
375
480
  */
376
481
  export type NormalizedAuthModelConfig = {
377
482
  modelName: string
483
+ tableName?: string
378
484
  fields: Record<string, string>
379
485
  schema?: string
380
486
  }