@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
@@ -1,11 +1,58 @@
1
1
  import { betterAuth } from 'better-auth'
2
2
  import { prismaAdapter } from 'better-auth/adapters/prisma'
3
3
  import { nextCookies } from 'better-auth/next-js'
4
- import type { BetterAuthOptions } from 'better-auth'
5
- import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
4
+ import type { Auth, BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
5
+ import type { OpenSaasConfig, AccessContext, Session } from '@opensaas/stack-core'
6
6
  import type { DatabaseConfig } from '@opensaas/stack-core/internal'
7
7
  import type { NormalizedAuthConfig, NormalizedAuthModelConfig } from '../config/types.js'
8
8
 
9
+ /**
10
+ * The `BetterAuthOptions` shape produced when an app's own plugin tuple is
11
+ * passed to `buildBetterAuthOptions()`/`createAuth()` — the tuple plus the
12
+ * `nextCookies()` plugin the stack always appends last. Carrying the literal
13
+ * tuple type (rather than the widened `BetterAuthPlugin[]`) is what lets
14
+ * `betterAuth()` re-infer plugin endpoints (e.g. `emailOTP()`'s
15
+ * `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
16
+ * shape from the resulting options object.
17
+ */
18
+ type ResolvedBetterAuthOptions<TPlugins extends readonly BetterAuthPlugin[]> = Omit<
19
+ BetterAuthOptions,
20
+ 'plugins'
21
+ > & {
22
+ plugins: [...TPlugins, ReturnType<typeof nextCookies>]
23
+ }
24
+
25
+ /**
26
+ * Guard against the supplied plugin tuple silently drifting from the plugin
27
+ * array actually resolved from `authPlugin({ betterAuthPlugins })` — the
28
+ * supplied tuple exists for typing only, so if it isn't the exact same
29
+ * instances in the exact same order, the type it produces would be a lie
30
+ * about what `betterAuth()` is actually constructed with.
31
+ */
32
+ function assertPluginTupleMatchesResolved(
33
+ supplied: readonly BetterAuthPlugin[],
34
+ resolved: readonly BetterAuthPlugin[],
35
+ ): void {
36
+ if (supplied.length !== resolved.length) {
37
+ throw new Error(
38
+ '[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' +
39
+ `has ${supplied.length} plugin(s), but the plugin array resolved from \`authPlugin({ ` +
40
+ `betterAuthPlugins })\` has ${resolved.length}. Pass the exact same array (without ` +
41
+ '`nextCookies()` — the stack appends that itself).',
42
+ )
43
+ }
44
+
45
+ const mismatchIndex = supplied.findIndex((plugin, index) => plugin !== resolved[index])
46
+ if (mismatchIndex !== -1) {
47
+ throw new Error(
48
+ '[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' +
49
+ `does not match the plugin array resolved from \`authPlugin({ betterAuthPlugins })\` at index ` +
50
+ `${mismatchIndex} (got plugin "${supplied[mismatchIndex]?.id}", expected the same instance as ` +
51
+ `"${resolved[mismatchIndex]?.id}"). Pass the exact same array — same instances, same order.`,
52
+ )
53
+ }
54
+ }
55
+
9
56
  /**
10
57
  * Get better-auth database configuration from OpenSaas config
11
58
  */
@@ -34,112 +81,345 @@ function toBetterAuthModelOptions(
34
81
  return Object.keys(options).length > 0 ? options : undefined
35
82
  }
36
83
 
84
+ const MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH = [
85
+ 'user',
86
+ 'session',
87
+ 'account',
88
+ 'verification',
89
+ ] as const
90
+
91
+ /**
92
+ * Reject `betterAuthOptions` keys that already have a dedicated, non-passthrough
93
+ * seam — accepting them here would create two unranked ways to set the same
94
+ * thing, or (for `additionalFields`) silently diverge from the generated
95
+ * Prisma schema. See the `betterAuthOptions` doc comment on `AuthConfig`.
96
+ */
97
+ function assertNoUnsupportedPassthroughKeys(betterAuthOptions: Record<string, unknown>): void {
98
+ if ('database' in betterAuthOptions) {
99
+ throw new Error(
100
+ '[@opensaas/stack-auth] `betterAuthOptions.database` is not supported — the stack ' +
101
+ 'derives the database adapter from your `db` config and the running context. ' +
102
+ 'Configure the database through `db` in `opensaas.config.ts` instead.',
103
+ )
104
+ }
105
+
106
+ if ('plugins' in betterAuthOptions) {
107
+ throw new Error(
108
+ '[@opensaas/stack-auth] `betterAuthOptions.plugins` is not supported — better-auth ' +
109
+ 'plugins are added through `authPlugin({ betterAuthPlugins: [...] })`, which the stack ' +
110
+ 'appends `nextCookies()` after. Use `betterAuthPlugins` instead.',
111
+ )
112
+ }
113
+
114
+ for (const model of MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH) {
115
+ const modelOptions = betterAuthOptions[model]
116
+ if (
117
+ modelOptions &&
118
+ typeof modelOptions === 'object' &&
119
+ !Array.isArray(modelOptions) &&
120
+ 'additionalFields' in modelOptions
121
+ ) {
122
+ throw new Error(
123
+ `[@opensaas/stack-auth] \`betterAuthOptions.${model}.additionalFields\` is not ` +
124
+ 'supported — it adds columns that would not be reflected in the generated Prisma ' +
125
+ 'schema. Add fields to the derived list instead: ' +
126
+ (model === 'user'
127
+ ? '`extendUserList`, or declare the list yourself in your own `lists` config.'
128
+ : 'declare the derived list yourself in your own `lists` config (the auth plugin ' +
129
+ 'merges in field additions for the models it derives).'),
130
+ )
131
+ }
132
+ }
133
+ }
134
+
135
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
136
+ return (
137
+ typeof value === 'object' &&
138
+ value !== null &&
139
+ !Array.isArray(value) &&
140
+ Object.getPrototypeOf(value) === Object.prototype
141
+ )
142
+ }
143
+
144
+ /**
145
+ * Deep-merge `overrides` onto `base`, recursing into plain-object values so a
146
+ * nested addition (one database hook, one session sub-option) merges
147
+ * alongside sibling keys the stack already set there rather than replacing
148
+ * the whole branch. Arrays and any other value type replace outright.
149
+ * `overrides` wins on every key collision.
150
+ */
151
+ function mergeBetterAuthOptions(
152
+ base: Record<string, unknown>,
153
+ overrides: Record<string, unknown>,
154
+ ): Record<string, unknown> {
155
+ const result: Record<string, unknown> = { ...base }
156
+ for (const [key, value] of Object.entries(overrides)) {
157
+ const baseValue = result[key]
158
+ result[key] =
159
+ isPlainObject(baseValue) && isPlainObject(value)
160
+ ? mergeBetterAuthOptions(baseValue, value)
161
+ : value
162
+ }
163
+ return result
164
+ }
165
+
166
+ /**
167
+ * Build the `BetterAuthOptions` a better-auth instance for this OpenSaas
168
+ * config should be constructed with — the same options `createAuth()` uses
169
+ * internally, available standalone for an app that still needs to hand-wire
170
+ * its own `betterAuth()` instance (e.g. a third-party contract that requires
171
+ * a resolved instance at module-init time). Keeps the auth plugin
172
+ * authoritative for everything it models; the app's additions on top become
173
+ * an explicit, reviewable diff instead of a parallel, hand-duplicated config.
174
+ *
175
+ * Called with just `(config, context)`, the return type is the widened
176
+ * `BetterAuthOptions` — `betterAuth()` infers its plugin/session types from
177
+ * the *literal* type of the options object, so constructing from this
178
+ * widened return erases plugin endpoints (e.g. `emailOTP()`'s
179
+ * `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
180
+ * shape. **If your app reads `auth.api.*` in typed code and uses either of
181
+ * those, pass its plugin tuple as the third argument** — the exact same
182
+ * array already passed to `authPlugin({ betterAuthPlugins })` — so the
183
+ * return type carries the literal tuple instead:
184
+ *
185
+ * ```typescript
186
+ * import { betterAuth } from 'better-auth'
187
+ * import { emailOTP } from 'better-auth/plugins'
188
+ * import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
189
+ *
190
+ * export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
191
+ *
192
+ * export const auth = betterAuth({
193
+ * ...(await buildBetterAuthOptions(config, context, appBetterAuthPlugins)),
194
+ * databaseHooks: { user: { create: { after: syncDomainUser } } },
195
+ * })
196
+ * // auth.api.signInEmailOTP / auth.api.getSession()'s customSession shape are now typed.
197
+ * ```
198
+ *
199
+ * The supplied tuple is for typing only — the array actually used at runtime
200
+ * is always the one resolved from `authPlugin({ betterAuthPlugins })`, with
201
+ * exactly one `nextCookies()` appended last. Passing a tuple that isn't the
202
+ * same plugin instances in the same order throws, so the two can't silently
203
+ * drift apart.
204
+ *
205
+ * Note `createAuth()`'s lazy Proxy does not behave identically to a real
206
+ * `Auth` instance for every property (see its own doc comment) — reach for
207
+ * this builder plus `betterAuth()` instead when the app reads `auth.api.*`
208
+ * in typed code.
209
+ */
210
+ export async function buildBetterAuthOptions(
211
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
212
+ context: AccessContext | Promise<AccessContext>,
213
+ ): Promise<BetterAuthOptions>
214
+ export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
215
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
216
+ context: AccessContext | Promise<AccessContext>,
217
+ plugins: TPlugins,
218
+ ): Promise<ResolvedBetterAuthOptions<TPlugins>>
219
+ export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
220
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
221
+ context: AccessContext | Promise<AccessContext>,
222
+ plugins?: TPlugins,
223
+ ): Promise<BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>> {
224
+ const resolvedConfig = await Promise.resolve(opensaasConfig)
225
+ const resolvedContext = await Promise.resolve(context)
226
+
227
+ // Extract auth config from plugin data
228
+ const authConfig = resolvedConfig._pluginData?.auth as NormalizedAuthConfig | undefined
229
+
230
+ if (!authConfig) {
231
+ throw new Error(
232
+ 'Auth config not found. Make sure to use authPlugin() in your opensaas.config.ts',
233
+ )
234
+ }
235
+
236
+ // `requireConfirmation` has no better-auth equivalent — it's a UI-only
237
+ // concern the pre-built forms already take as their own
238
+ // `requirePasswordConfirmation` prop. Warn rather than silently drop it,
239
+ // since setting it here looks like it should do something.
240
+ if (
241
+ authConfig.emailAndPassword.enabled &&
242
+ authConfig.emailAndPassword.requireConfirmation !== true
243
+ ) {
244
+ console.warn(
245
+ '[@opensaas/stack-auth] `emailAndPassword.requireConfirmation` has no effect here — ' +
246
+ 'createAuth() has no better-auth option to forward it to. Pass ' +
247
+ '`requirePasswordConfirmation` directly to <SignUpForm> / <ResetPasswordForm> instead.',
248
+ )
249
+ }
250
+
251
+ // `passwordReset` is wired through better-auth's `emailAndPassword` config
252
+ // (there's no password to reset without a password-based account), so it
253
+ // silently has no effect if email/password auth itself isn't enabled.
254
+ if (authConfig.passwordReset.enabled && !authConfig.emailAndPassword.enabled) {
255
+ console.warn(
256
+ '[@opensaas/stack-auth] `passwordReset.enabled` has no effect here — ' +
257
+ '`emailAndPassword.enabled` is false, so there is no password-based account to reset.',
258
+ )
259
+ }
260
+
261
+ assertNoUnsupportedPassthroughKeys(authConfig.betterAuthOptions as Record<string, unknown>)
262
+
263
+ const resolvedPlugins = authConfig.betterAuthPlugins || []
264
+ if (plugins) {
265
+ assertPluginTupleMatchesResolved(plugins, resolvedPlugins)
266
+ }
267
+
268
+ // Build better-auth configuration
269
+ const betterAuthConfig: BetterAuthOptions = {
270
+ database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
271
+
272
+ // Mirror the per-model config (modelName + field column maps) back to
273
+ // better-auth so the running auth instance reads/writes the same
274
+ // tables/columns the OpenSaaS Auth lists were derived from.
275
+ user: toBetterAuthModelOptions(authConfig.models.user),
276
+ session: {
277
+ ...toBetterAuthModelOptions(authConfig.models.session),
278
+ expiresIn: authConfig.session.expiresIn || 604800,
279
+ // better-auth treats `updateAge: 0` as "refresh on every request", not
280
+ // "never refresh" — disabling refresh entirely requires its separate
281
+ // `disableSessionRefresh` flag regardless of `updateAge`.
282
+ ...(authConfig.session.updateAge === false
283
+ ? { disableSessionRefresh: true }
284
+ : { updateAge: authConfig.session.updateAge }),
285
+ },
286
+ account: toBetterAuthModelOptions(authConfig.models.account),
287
+ verification: toBetterAuthModelOptions(authConfig.models.verification),
288
+
289
+ // Enable email and password if configured
290
+ emailAndPassword: authConfig.emailAndPassword.enabled
291
+ ? {
292
+ enabled: true,
293
+ requireEmailVerification: authConfig.emailVerification.enabled,
294
+ minPasswordLength: authConfig.emailAndPassword.minPasswordLength,
295
+ ...(authConfig.passwordReset.enabled
296
+ ? {
297
+ sendResetPassword: authConfig.emailAndPassword.sendResetPassword,
298
+ resetPasswordTokenExpiresIn: authConfig.passwordReset.tokenExpiration,
299
+ }
300
+ : {}),
301
+ }
302
+ : undefined,
303
+
304
+ // Email verification (independent of emailAndPassword — also covers
305
+ // e.g. a social-provider account whose email isn't yet verified)
306
+ emailVerification: authConfig.emailVerification.enabled
307
+ ? {
308
+ sendVerificationEmail: authConfig.emailVerification.sendVerificationEmail,
309
+ sendOnSignUp: authConfig.emailVerification.sendOnSignUp,
310
+ expiresIn: authConfig.emailVerification.tokenExpiration,
311
+ }
312
+ : undefined,
313
+
314
+ // Trust host (required for production)
315
+ trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
316
+
317
+ // Social providers
318
+ socialProviders: Object.entries(authConfig.socialProviders)
319
+ .filter(([_, config]) => config?.enabled !== false)
320
+ .reduce(
321
+ (acc, [provider, config]) => {
322
+ if (config) {
323
+ acc[provider] = {
324
+ clientId: config.clientId,
325
+ clientSecret: config.clientSecret,
326
+ }
327
+ }
328
+ return acc
329
+ },
330
+ {} as Record<string, { clientId: string; clientSecret: string }>,
331
+ ),
332
+
333
+ // Rate limiting configuration
334
+ rateLimit: authConfig.rateLimit
335
+ ? {
336
+ enabled: authConfig.rateLimit.enabled,
337
+ window: authConfig.rateLimit.window,
338
+ max: authConfig.rateLimit.max,
339
+ }
340
+ : undefined,
341
+
342
+ // Pass through any additional Better Auth plugins, then append
343
+ // nextCookies LAST so it can write the Set-Cookie headers produced by
344
+ // any auth.api.* call made inside a Next.js server action into Next's
345
+ // cookie store. This is what makes the server-action auth forms (which
346
+ // call auth.api.signInEmail/signUpEmail/etc. server-side) actually
347
+ // persist a session. It must be the final plugin in the array.
348
+ plugins: [...resolvedPlugins, nextCookies()],
349
+ }
350
+
351
+ return mergeBetterAuthOptions(
352
+ betterAuthConfig as unknown as Record<string, unknown>,
353
+ authConfig.betterAuthOptions as Record<string, unknown>,
354
+ ) as BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>
355
+ }
356
+
37
357
  /**
38
358
  * Create a better-auth instance from OpenSaas config
39
359
  * This should be called once at app startup
40
360
  *
41
- * @example
361
+ * Returns a lazy `Proxy` (see the caveat below), typed as `Auth<BetterAuthOptions>`
362
+ * when called with just `(config, context)` — the widened type, same erasure
363
+ * caveat as {@link buildBetterAuthOptions}'s no-argument form. **If your app
364
+ * reads `auth.api.*` in typed code and relies on a plugin's endpoints (e.g.
365
+ * `emailOTP()`) or a `customSession()`'s replaced session shape, pass its
366
+ * plugin tuple as the third argument** — the exact same array already passed
367
+ * to `authPlugin({ betterAuthPlugins })` — so the declared type carries the
368
+ * literal tuple instead:
369
+ *
42
370
  * ```typescript
43
371
  * // lib/auth.ts
44
372
  * import { createAuth } from '@opensaas/stack-auth/server'
45
373
  * import config from '../opensaas.config'
46
374
  * import { rawOpensaasContext } from '@/.opensaas/context'
47
375
  *
48
- * export const auth = createAuth(config, rawOpensaasContext)
376
+ * export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
377
+ *
378
+ * export const auth = createAuth(config, rawOpensaasContext, appBetterAuthPlugins)
49
379
  * ```
380
+ *
381
+ * As with the builder, the supplied tuple is for typing only, and a tuple
382
+ * that isn't the same plugin instances in the same order throws.
383
+ *
384
+ * **Proxy caveat:** the lazy `Proxy` this returns does not behave identically
385
+ * to a real `Auth` instance for every property — every access, including a
386
+ * non-function property, is surfaced through an `async` wrapper (so e.g.
387
+ * `auth.options` reads back as a `Promise`, not the plain object a real
388
+ * instance would return synchronously). The declared type does not model
389
+ * this difference; where it matters, reach for {@link buildBetterAuthOptions}
390
+ * plus `betterAuth()` instead, which constructs a real instance.
50
391
  */
51
392
  export function createAuth(
52
393
  opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
53
394
  context: AccessContext | Promise<AccessContext>,
54
- ) {
395
+ ): Auth<BetterAuthOptions>
396
+ export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
397
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
398
+ context: AccessContext | Promise<AccessContext>,
399
+ plugins: TPlugins,
400
+ ): Auth<ResolvedBetterAuthOptions<TPlugins>>
401
+ export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
402
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
403
+ context: AccessContext | Promise<AccessContext>,
404
+ plugins?: TPlugins,
405
+ ): Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>> {
55
406
  // Resolve config and context asynchronously
56
407
  const configPromise = Promise.resolve(opensaasConfig)
57
408
  const contextPromise = Promise.resolve(context)
58
409
 
59
410
  // Create auth instance lazily when needed
60
- let authInstance: ReturnType<typeof betterAuth> | null = null
61
- let authPromise: Promise<ReturnType<typeof betterAuth>> | null = null
411
+ type AuthInstance = Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>>
412
+ let authInstance: AuthInstance | null = null
413
+ let authPromise: Promise<AuthInstance> | null = null
62
414
 
63
415
  async function getAuthInstance() {
64
416
  if (authInstance) return authInstance
65
417
 
66
418
  if (!authPromise) {
67
419
  authPromise = (async () => {
68
- const resolvedConfig = await configPromise
69
- const resolvedContext = await contextPromise
70
-
71
- // Extract auth config from plugin data
72
- const authConfig = resolvedConfig._pluginData?.auth as NormalizedAuthConfig | undefined
73
-
74
- if (!authConfig) {
75
- throw new Error(
76
- 'Auth config not found. Make sure to use authPlugin() in your opensaas.config.ts',
77
- )
78
- }
79
-
80
- // Build better-auth configuration
81
- const betterAuthConfig: BetterAuthOptions = {
82
- database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
83
-
84
- // Mirror the per-model config (modelName + field column maps) back to
85
- // better-auth so the running auth instance reads/writes the same
86
- // tables/columns the OpenSaaS Auth lists were derived from.
87
- user: toBetterAuthModelOptions(authConfig.models.user),
88
- session: {
89
- ...toBetterAuthModelOptions(authConfig.models.session),
90
- expiresIn: authConfig.session.expiresIn || 604800,
91
- updateAge: authConfig.session.updateAge
92
- ? (authConfig.session.expiresIn || 604800) / 10
93
- : 0,
94
- },
95
- account: toBetterAuthModelOptions(authConfig.models.account),
96
- verification: toBetterAuthModelOptions(authConfig.models.verification),
97
-
98
- // Enable email and password if configured
99
- emailAndPassword: authConfig.emailAndPassword.enabled
100
- ? {
101
- enabled: true,
102
- requireEmailVerification: authConfig.emailVerification.enabled,
103
- }
104
- : undefined,
105
-
106
- // Trust host (required for production)
107
- trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
108
-
109
- // Social providers
110
- socialProviders: Object.entries(authConfig.socialProviders)
111
- .filter(([_, config]) => config?.enabled !== false)
112
- .reduce(
113
- (acc, [provider, config]) => {
114
- if (config) {
115
- acc[provider] = {
116
- clientId: config.clientId,
117
- clientSecret: config.clientSecret,
118
- }
119
- }
120
- return acc
121
- },
122
- {} as Record<string, { clientId: string; clientSecret: string }>,
123
- ),
124
-
125
- // Rate limiting configuration
126
- rateLimit: authConfig.rateLimit
127
- ? {
128
- enabled: authConfig.rateLimit.enabled,
129
- window: authConfig.rateLimit.window,
130
- max: authConfig.rateLimit.max,
131
- }
132
- : undefined,
133
-
134
- // Pass through any additional Better Auth plugins, then append
135
- // nextCookies LAST so it can write the Set-Cookie headers produced by
136
- // any auth.api.* call made inside a Next.js server action into Next's
137
- // cookie store. This is what makes the server-action auth forms (which
138
- // call auth.api.signInEmail/signUpEmail/etc. server-side) actually
139
- // persist a session. It must be the final plugin in the array.
140
- plugins: [...(authConfig.betterAuthPlugins || []), nextCookies()],
141
- }
142
-
420
+ const betterAuthConfig = plugins
421
+ ? await buildBetterAuthOptions(configPromise, contextPromise, plugins)
422
+ : await buildBetterAuthOptions(configPromise, contextPromise)
143
423
  authInstance = betterAuth(betterAuthConfig)
144
424
  return authInstance
145
425
  })()
@@ -149,7 +429,7 @@ export function createAuth(
149
429
  }
150
430
 
151
431
  // Return a proxy that lazily initializes the auth instance
152
- return new Proxy({} as ReturnType<typeof betterAuth>, {
432
+ return new Proxy({} as AuthInstance, {
153
433
  get(_, prop) {
154
434
  if (prop === 'then') {
155
435
  // Support await on the proxy itself
@@ -195,37 +475,121 @@ export function createAuth(
195
475
  }
196
476
 
197
477
  /**
198
- * Get session from better-auth and transform it to OpenSaas session format
199
- * This is used internally by the generated context
478
+ * Field names already warned about failing to resolve against a session, so a
479
+ * given field warns at most once per process rather than once per request.
480
+ */
481
+ const unresolvedSessionFieldWarnings = new Set<string>()
482
+
483
+ /**
484
+ * Warn (once per field, per process) that a `sessionFields` entry could not
485
+ * be resolved from the session shape `auth.api.getSession()` actually
486
+ * returned — naming the field and what keys were available to check, so the
487
+ * gap is visible here instead of surfacing later as an access-control
488
+ * function silently reading `undefined`.
489
+ */
490
+ function warnUnresolvedSessionField(field: string, resolvedSession: Record<string, unknown>): void {
491
+ if (unresolvedSessionFieldWarnings.has(field)) return
492
+ unresolvedSessionFieldWarnings.add(field)
493
+
494
+ const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
495
+ const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
496
+
497
+ console.warn(
498
+ `[@opensaas/stack-auth] sessionFields: "${field}" was not found on the resolved session. ` +
499
+ `Checked its top-level keys (${Object.keys(resolvedSession).join(', ') || 'none'}), ` +
500
+ `its "user" object (${user ? Object.keys(user).join(', ') || 'none' : 'not present'}), ` +
501
+ `and its "session" object (${sessionRow ? Object.keys(sessionRow).join(', ') || 'none' : 'not present'}). ` +
502
+ `The field is omitted from the projected session. A \`customSession\` plugin that nests this ` +
503
+ `value elsewhere is the app's own to reconcile — see the \`sessionFields\` reference. ` +
504
+ `This warning will not repeat for "${field}".`,
505
+ )
506
+ }
507
+
508
+ /**
509
+ * Resolve a single `sessionFields` entry off the resolved better-auth
510
+ * session (whatever `auth.api.getSession()` returned — the default `{
511
+ * session, user }` shape, or a `customSession` plugin's replaced shape).
512
+ *
513
+ * `userId` is special-cased to the authenticated user's `id` — the
514
+ * documented default apps depend on. Every other name resolves against a
515
+ * fixed precedence so a collision between sources is predictable rather
516
+ * than incidental: a top-level key on the resolved session object, then the
517
+ * `user` object, then the `session` sub-object.
518
+ */
519
+ function resolveSessionField(
520
+ field: string,
521
+ resolvedSession: Record<string, unknown>,
522
+ ): { found: true; value: unknown } | { found: false } {
523
+ const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
524
+
525
+ if (field === 'userId') {
526
+ return user && 'id' in user ? { found: true, value: user.id } : { found: false }
527
+ }
528
+
529
+ if (field in resolvedSession) {
530
+ return { found: true, value: resolvedSession[field] }
531
+ }
532
+ if (user && field in user) {
533
+ return { found: true, value: user[field] }
534
+ }
535
+ const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
536
+ if (sessionRow && field in sessionRow) {
537
+ return { found: true, value: sessionRow[field] }
538
+ }
539
+ return { found: false }
540
+ }
541
+
542
+ /**
543
+ * Get session from better-auth and transform it to OpenSaas session format —
544
+ * a flattened projection of `sessionFields` off the *resolved* session
545
+ * object, not just its `user` sub-object. This is what makes a
546
+ * `customSession` plugin's fields (added at the top level, or a
547
+ * session-only field like the admin plugin's `impersonatedBy`) reachable.
548
+ * See the `sessionFields` reference for the resolution precedence.
549
+ *
550
+ * Returns `null` only when there is genuinely no session — a resolved
551
+ * session with no `user` key (a `customSession` plugin that dropped it) is
552
+ * still a session and still gets projected, never misreported as anonymous.
553
+ * A listed field that can't be resolved from the session shape is omitted
554
+ * and warns once per field per process (see `warnUnresolvedSessionField`)
555
+ * instead of silently vanishing into an access-control function reading
556
+ * `undefined`.
557
+ *
558
+ * Errors from the underlying `auth.api.getSession()` call propagate rather
559
+ * than becoming `null` — collapsing a lookup failure (e.g. a session-store
560
+ * outage) into "anonymous" is indistinguishable from a mass sign-out under
561
+ * fail-closed access control, so the caller must see it.
562
+ *
563
+ * Not called by any generated code before this helper existed — apps used to
564
+ * hand-roll this same transform against `auth.api.getSession({ headers:
565
+ * await headers() })`. Exported as the single reusable implementation; pass
566
+ * the caller's request headers (e.g. Next.js `await headers()` in a Server
567
+ * Component/action) so a session cookie can actually be resolved.
200
568
  */
201
569
  export async function getSessionFromAuth(
202
570
  auth: ReturnType<typeof betterAuth>,
203
571
  sessionFields: string[],
204
- ) {
205
- try {
206
- const session = await auth.api.getSession({
207
- headers: new Headers(),
208
- })
209
-
210
- if (!session?.user) {
211
- return null
212
- }
572
+ headers: Headers,
573
+ ): Promise<Session | null> {
574
+ const resolvedSession = await auth.api.getSession({ headers })
213
575
 
214
- // Build session object with requested fields
215
- const result: Record<string, unknown> = {}
576
+ if (!resolvedSession) {
577
+ return null
578
+ }
216
579
 
217
- for (const field of sessionFields) {
218
- if (field === 'userId') {
219
- result.userId = session.user.id
220
- } else if (field in session.user) {
221
- result[field] = session.user[field as keyof typeof session.user]
222
- }
223
- }
580
+ const resolvedSessionRecord = resolvedSession as Record<string, unknown>
581
+ const result: Record<string, unknown> = {}
224
582
 
225
- return result
226
- } catch {
227
- return null
583
+ for (const field of sessionFields) {
584
+ const resolved = resolveSessionField(field, resolvedSessionRecord)
585
+ if (resolved.found) {
586
+ result[field] = resolved.value
587
+ } else {
588
+ warnUnresolvedSessionField(field, resolvedSessionRecord)
589
+ }
228
590
  }
591
+
592
+ return result
229
593
  }
230
594
 
231
595
  export type { BetterAuthOptions }
@@ -12,13 +12,13 @@ import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
12
12
  * Inferred from better-auth internal types
13
13
  */
14
14
  type BetterAuthFieldAttribute = {
15
- type: string // 'string' | 'number' | 'boolean' | 'date' | etc.
15
+ type: string | string[] // 'string' | 'number' | 'boolean' | 'date' | etc., or an enum array
16
16
  required?: boolean
17
17
  unique?: boolean
18
18
  references?: {
19
19
  model: string
20
20
  field: string
21
- onDelete?: 'cascade' | 'set null' | 'restrict'
21
+ onDelete?: 'no action' | 'restrict' | 'cascade' | 'set null' | 'set default'
22
22
  }
23
23
  defaultValue?: unknown
24
24
  returned?: boolean
@@ -29,7 +29,7 @@ type BetterAuthFieldAttribute = {
29
29
  * Better Auth table schema structure
30
30
  */
31
31
  type BetterAuthTableSchema = {
32
- modelName: string
32
+ modelName?: string
33
33
  fields: Record<string, BetterAuthFieldAttribute>
34
34
  }
35
35