@opensaas/stack-auth 0.37.0 → 0.39.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 (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +60 -0
  3. package/CLAUDE.md +50 -3
  4. package/dist/config/adopt-better-auth-tables.d.ts +23 -3
  5. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  6. package/dist/config/adopt-better-auth-tables.js +7 -2
  7. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  8. package/dist/config/derive-auth-lists.d.ts +6 -1
  9. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  10. package/dist/config/derive-auth-lists.js +63 -16
  11. package/dist/config/derive-auth-lists.js.map +1 -1
  12. package/dist/config/index.d.ts.map +1 -1
  13. package/dist/config/index.js +12 -5
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/config/plugin.d.ts.map +1 -1
  16. package/dist/config/plugin.js +7 -2
  17. package/dist/config/plugin.js.map +1 -1
  18. package/dist/config/types.d.ts +62 -7
  19. package/dist/config/types.d.ts.map +1 -1
  20. package/dist/server/build-better-auth-options.test.d.ts +2 -0
  21. package/dist/server/build-better-auth-options.test.d.ts.map +1 -0
  22. package/dist/server/build-better-auth-options.test.js +29 -0
  23. package/dist/server/build-better-auth-options.test.js.map +1 -0
  24. package/dist/server/get-session-from-auth.test.d.ts +2 -0
  25. package/dist/server/get-session-from-auth.test.d.ts.map +1 -0
  26. package/dist/server/get-session-from-auth.test.js +25 -0
  27. package/dist/server/get-session-from-auth.test.js.map +1 -0
  28. package/dist/server/index.d.ts +108 -15
  29. package/dist/server/index.d.ts.map +1 -1
  30. package/dist/server/index.js +151 -63
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/schema-converter.d.ts +15 -6
  33. package/dist/server/schema-converter.d.ts.map +1 -1
  34. package/dist/server/schema-converter.js +14 -2
  35. package/dist/server/schema-converter.js.map +1 -1
  36. package/package.json +5 -5
  37. package/src/config/adopt-better-auth-tables.ts +31 -4
  38. package/src/config/derive-auth-lists.ts +82 -21
  39. package/src/config/index.ts +18 -5
  40. package/src/config/plugin.ts +7 -2
  41. package/src/config/types.ts +63 -9
  42. package/src/server/build-better-auth-options.test.ts +59 -0
  43. package/src/server/get-session-from-auth.test.ts +52 -0
  44. package/src/server/index.ts +273 -41
  45. package/src/server/schema-converter.ts +29 -8
  46. package/tests/adopt-better-auth-tables.test.ts +73 -0
  47. package/tests/config.test.ts +161 -0
  48. package/tests/derive-auth-lists.test.ts +104 -0
  49. package/tests/generated-fk-shape.test.ts +81 -0
  50. package/tests/plugin-schema-placement.test.ts +39 -0
  51. package/tests/rate-limit-e2e.test.ts +239 -0
  52. package/tests/schema-converter.test.ts +58 -0
  53. package/tests/server.test.ts +310 -4
  54. package/tsconfig.tsbuildinfo +1 -1
  55. 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
  */
@@ -64,6 +111,21 @@ function assertNoUnsupportedPassthroughKeys(betterAuthOptions: Record<string, un
64
111
  )
65
112
  }
66
113
 
114
+ const rateLimitOptions = betterAuthOptions.rateLimit
115
+ if (
116
+ rateLimitOptions &&
117
+ typeof rateLimitOptions === 'object' &&
118
+ !Array.isArray(rateLimitOptions) &&
119
+ 'storage' in rateLimitOptions
120
+ ) {
121
+ throw new Error(
122
+ '[@opensaas/stack-auth] `betterAuthOptions.rateLimit.storage` is not supported — it has ' +
123
+ 'schema consequences (deriving the `RateLimit` list) that a passthrough cannot also ' +
124
+ "apply to the generated Prisma schema. Use `authPlugin({ rateLimit: { storage: 'database' } })` " +
125
+ 'instead.',
126
+ )
127
+ }
128
+
67
129
  for (const model of MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH) {
68
130
  const modelOptions = betterAuthOptions[model]
69
131
  if (
@@ -125,21 +187,55 @@ function mergeBetterAuthOptions(
125
187
  * authoritative for everything it models; the app's additions on top become
126
188
  * an explicit, reviewable diff instead of a parallel, hand-duplicated config.
127
189
  *
128
- * @example
190
+ * Called with just `(config, context)`, the return type is the widened
191
+ * `BetterAuthOptions` — `betterAuth()` infers its plugin/session types from
192
+ * the *literal* type of the options object, so constructing from this
193
+ * widened return erases plugin endpoints (e.g. `emailOTP()`'s
194
+ * `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
195
+ * shape. **If your app reads `auth.api.*` in typed code and uses either of
196
+ * those, pass its plugin tuple as the third argument** — the exact same
197
+ * array already passed to `authPlugin({ betterAuthPlugins })` — so the
198
+ * return type carries the literal tuple instead:
199
+ *
129
200
  * ```typescript
130
201
  * import { betterAuth } from 'better-auth'
202
+ * import { emailOTP } from 'better-auth/plugins'
131
203
  * import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
132
204
  *
205
+ * export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
206
+ *
133
207
  * export const auth = betterAuth({
134
- * ...(await buildBetterAuthOptions(config, context)),
208
+ * ...(await buildBetterAuthOptions(config, context, appBetterAuthPlugins)),
135
209
  * databaseHooks: { user: { create: { after: syncDomainUser } } },
136
210
  * })
211
+ * // auth.api.signInEmailOTP / auth.api.getSession()'s customSession shape are now typed.
137
212
  * ```
213
+ *
214
+ * The supplied tuple is for typing only — the array actually used at runtime
215
+ * is always the one resolved from `authPlugin({ betterAuthPlugins })`, with
216
+ * exactly one `nextCookies()` appended last. Passing a tuple that isn't the
217
+ * same plugin instances in the same order throws, so the two can't silently
218
+ * drift apart.
219
+ *
220
+ * Note `createAuth()`'s lazy Proxy does not behave identically to a real
221
+ * `Auth` instance for every property (see its own doc comment) — reach for
222
+ * this builder plus `betterAuth()` instead when the app reads `auth.api.*`
223
+ * in typed code.
138
224
  */
139
225
  export async function buildBetterAuthOptions(
140
226
  opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
141
227
  context: AccessContext | Promise<AccessContext>,
142
- ): Promise<BetterAuthOptions> {
228
+ ): Promise<BetterAuthOptions>
229
+ export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
230
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
231
+ context: AccessContext | Promise<AccessContext>,
232
+ plugins: TPlugins,
233
+ ): Promise<ResolvedBetterAuthOptions<TPlugins>>
234
+ export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
235
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
236
+ context: AccessContext | Promise<AccessContext>,
237
+ plugins?: TPlugins,
238
+ ): Promise<BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>> {
143
239
  const resolvedConfig = await Promise.resolve(opensaasConfig)
144
240
  const resolvedContext = await Promise.resolve(context)
145
241
 
@@ -179,6 +275,11 @@ export async function buildBetterAuthOptions(
179
275
 
180
276
  assertNoUnsupportedPassthroughKeys(authConfig.betterAuthOptions as Record<string, unknown>)
181
277
 
278
+ const resolvedPlugins = authConfig.betterAuthPlugins || []
279
+ if (plugins) {
280
+ assertPluginTupleMatchesResolved(plugins, resolvedPlugins)
281
+ }
282
+
182
283
  // Build better-auth configuration
183
284
  const betterAuthConfig: BetterAuthOptions = {
184
285
  database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
@@ -244,12 +345,19 @@ export async function buildBetterAuthOptions(
244
345
  {} as Record<string, { clientId: string; clientSecret: string }>,
245
346
  ),
246
347
 
247
- // Rate limiting configuration
348
+ // Rate limiting configuration. `modelName`/`fields` are only forwarded
349
+ // when `models.rateLimit` was derived (rateLimit.storage === 'database')
350
+ // — mirroring the running instance's model options back to the table the
351
+ // `RateLimit` Auth list was derived from.
248
352
  rateLimit: authConfig.rateLimit
249
353
  ? {
250
354
  enabled: authConfig.rateLimit.enabled,
251
355
  window: authConfig.rateLimit.window,
252
356
  max: authConfig.rateLimit.max,
357
+ ...(authConfig.rateLimit.storage ? { storage: authConfig.rateLimit.storage } : {}),
358
+ ...(authConfig.models.rateLimit
359
+ ? toBetterAuthModelOptions(authConfig.models.rateLimit)
360
+ : {}),
253
361
  }
254
362
  : undefined,
255
363
 
@@ -259,47 +367,81 @@ export async function buildBetterAuthOptions(
259
367
  // cookie store. This is what makes the server-action auth forms (which
260
368
  // call auth.api.signInEmail/signUpEmail/etc. server-side) actually
261
369
  // persist a session. It must be the final plugin in the array.
262
- plugins: [...(authConfig.betterAuthPlugins || []), nextCookies()],
370
+ plugins: [...resolvedPlugins, nextCookies()],
263
371
  }
264
372
 
265
373
  return mergeBetterAuthOptions(
266
374
  betterAuthConfig as unknown as Record<string, unknown>,
267
375
  authConfig.betterAuthOptions as Record<string, unknown>,
268
- ) as BetterAuthOptions
376
+ ) as BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>
269
377
  }
270
378
 
271
379
  /**
272
380
  * Create a better-auth instance from OpenSaas config
273
381
  * This should be called once at app startup
274
382
  *
275
- * @example
383
+ * Returns a lazy `Proxy` (see the caveat below), typed as `Auth<BetterAuthOptions>`
384
+ * when called with just `(config, context)` — the widened type, same erasure
385
+ * caveat as {@link buildBetterAuthOptions}'s no-argument form. **If your app
386
+ * reads `auth.api.*` in typed code and relies on a plugin's endpoints (e.g.
387
+ * `emailOTP()`) or a `customSession()`'s replaced session shape, pass its
388
+ * plugin tuple as the third argument** — the exact same array already passed
389
+ * to `authPlugin({ betterAuthPlugins })` — so the declared type carries the
390
+ * literal tuple instead:
391
+ *
276
392
  * ```typescript
277
393
  * // lib/auth.ts
278
394
  * import { createAuth } from '@opensaas/stack-auth/server'
279
395
  * import config from '../opensaas.config'
280
396
  * import { rawOpensaasContext } from '@/.opensaas/context'
281
397
  *
282
- * export const auth = createAuth(config, rawOpensaasContext)
398
+ * export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
399
+ *
400
+ * export const auth = createAuth(config, rawOpensaasContext, appBetterAuthPlugins)
283
401
  * ```
402
+ *
403
+ * As with the builder, the supplied tuple is for typing only, and a tuple
404
+ * that isn't the same plugin instances in the same order throws.
405
+ *
406
+ * **Proxy caveat:** the lazy `Proxy` this returns does not behave identically
407
+ * to a real `Auth` instance for every property — every access, including a
408
+ * non-function property, is surfaced through an `async` wrapper (so e.g.
409
+ * `auth.options` reads back as a `Promise`, not the plain object a real
410
+ * instance would return synchronously). The declared type does not model
411
+ * this difference; where it matters, reach for {@link buildBetterAuthOptions}
412
+ * plus `betterAuth()` instead, which constructs a real instance.
284
413
  */
285
414
  export function createAuth(
286
415
  opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
287
416
  context: AccessContext | Promise<AccessContext>,
288
- ) {
417
+ ): Auth<BetterAuthOptions>
418
+ export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
419
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
420
+ context: AccessContext | Promise<AccessContext>,
421
+ plugins: TPlugins,
422
+ ): Auth<ResolvedBetterAuthOptions<TPlugins>>
423
+ export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
424
+ opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
425
+ context: AccessContext | Promise<AccessContext>,
426
+ plugins?: TPlugins,
427
+ ): Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>> {
289
428
  // Resolve config and context asynchronously
290
429
  const configPromise = Promise.resolve(opensaasConfig)
291
430
  const contextPromise = Promise.resolve(context)
292
431
 
293
432
  // Create auth instance lazily when needed
294
- let authInstance: ReturnType<typeof betterAuth> | null = null
295
- let authPromise: Promise<ReturnType<typeof betterAuth>> | null = null
433
+ type AuthInstance = Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>>
434
+ let authInstance: AuthInstance | null = null
435
+ let authPromise: Promise<AuthInstance> | null = null
296
436
 
297
437
  async function getAuthInstance() {
298
438
  if (authInstance) return authInstance
299
439
 
300
440
  if (!authPromise) {
301
441
  authPromise = (async () => {
302
- const betterAuthConfig = await buildBetterAuthOptions(configPromise, contextPromise)
442
+ const betterAuthConfig = plugins
443
+ ? await buildBetterAuthOptions(configPromise, contextPromise, plugins)
444
+ : await buildBetterAuthOptions(configPromise, contextPromise)
303
445
  authInstance = betterAuth(betterAuthConfig)
304
446
  return authInstance
305
447
  })()
@@ -309,7 +451,7 @@ export function createAuth(
309
451
  }
310
452
 
311
453
  // Return a proxy that lazily initializes the auth instance
312
- return new Proxy({} as ReturnType<typeof betterAuth>, {
454
+ return new Proxy({} as AuthInstance, {
313
455
  get(_, prop) {
314
456
  if (prop === 'then') {
315
457
  // Support await on the proxy itself
@@ -355,41 +497,131 @@ export function createAuth(
355
497
  }
356
498
 
357
499
  /**
358
- * Get session from better-auth and transform it to OpenSaas session format.
500
+ * Field names already warned about failing to resolve against a session, so a
501
+ * given field warns at most once per process rather than once per request.
502
+ */
503
+ const unresolvedSessionFieldWarnings = new Set<string>()
504
+
505
+ /**
506
+ * Warn (once per field, per process) that a `sessionFields` entry could not
507
+ * be resolved from the session shape `auth.api.getSession()` actually
508
+ * returned — naming the field and what keys were available to check, so the
509
+ * gap is visible here instead of surfacing later as an access-control
510
+ * function silently reading `undefined`.
511
+ */
512
+ function warnUnresolvedSessionField(field: string, resolvedSession: Record<string, unknown>): void {
513
+ if (unresolvedSessionFieldWarnings.has(field)) return
514
+ unresolvedSessionFieldWarnings.add(field)
515
+
516
+ const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
517
+ const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
518
+
519
+ console.warn(
520
+ `[@opensaas/stack-auth] sessionFields: "${field}" was not found on the resolved session. ` +
521
+ `Checked its top-level keys (${Object.keys(resolvedSession).join(', ') || 'none'}), ` +
522
+ `its "user" object (${user ? Object.keys(user).join(', ') || 'none' : 'not present'}), ` +
523
+ `and its "session" object (${sessionRow ? Object.keys(sessionRow).join(', ') || 'none' : 'not present'}). ` +
524
+ `The field is omitted from the projected session. A \`customSession\` plugin that nests this ` +
525
+ `value elsewhere is the app's own to reconcile — see the \`sessionFields\` reference. ` +
526
+ `This warning will not repeat for "${field}".`,
527
+ )
528
+ }
529
+
530
+ /**
531
+ * Resolve a single `sessionFields` entry off the resolved better-auth
532
+ * session (whatever `auth.api.getSession()` returned — the default `{
533
+ * session, user }` shape, or a `customSession` plugin's replaced shape).
534
+ *
535
+ * `userId` is special-cased to the authenticated user's `id` — the
536
+ * documented default apps depend on. Every other name resolves against a
537
+ * fixed precedence so a collision between sources is predictable rather
538
+ * than incidental: a top-level key on the resolved session object, then the
539
+ * `user` object, then the `session` sub-object.
540
+ */
541
+ function resolveSessionField(
542
+ field: string,
543
+ resolvedSession: Record<string, unknown>,
544
+ ): { found: true; value: unknown } | { found: false } {
545
+ const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
546
+
547
+ if (field === 'userId') {
548
+ return user && 'id' in user ? { found: true, value: user.id } : { found: false }
549
+ }
550
+
551
+ if (field in resolvedSession) {
552
+ return { found: true, value: resolvedSession[field] }
553
+ }
554
+ if (user && field in user) {
555
+ return { found: true, value: user[field] }
556
+ }
557
+ const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
558
+ if (sessionRow && field in sessionRow) {
559
+ return { found: true, value: sessionRow[field] }
560
+ }
561
+ return { found: false }
562
+ }
563
+
564
+ /**
565
+ * Get session from better-auth and transform it to OpenSaas session format —
566
+ * a flattened projection of `sessionFields` off the *resolved* session
567
+ * object, not just its `user` sub-object. This is what makes a
568
+ * `customSession` plugin's fields (added at the top level, or a
569
+ * session-only field like the admin plugin's `impersonatedBy`) reachable.
570
+ * See the `sessionFields` reference for the resolution precedence.
571
+ *
572
+ * Returns `null` only when there is genuinely no session — a resolved
573
+ * session with no `user` key (a `customSession` plugin that dropped it) is
574
+ * still a session and still gets projected, never misreported as anonymous.
575
+ * A listed field that can't be resolved from the session shape is omitted
576
+ * and warns once per field per process (see `warnUnresolvedSessionField`)
577
+ * instead of silently vanishing into an access-control function reading
578
+ * `undefined`.
579
+ *
580
+ * Errors from the underlying `auth.api.getSession()` call propagate rather
581
+ * than becoming `null` — collapsing a lookup failure (e.g. a session-store
582
+ * outage) into "anonymous" is indistinguishable from a mass sign-out under
583
+ * fail-closed access control, so the caller must see it.
584
+ *
585
+ * Not called by any generated code before this helper existed — apps used to
586
+ * hand-roll this same transform against `auth.api.getSession({ headers:
587
+ * await headers() })`. Exported as the single reusable implementation; pass
588
+ * the caller's request headers (e.g. Next.js `await headers()` in a Server
589
+ * Component/action) so a session cookie can actually be resolved.
359
590
  *
360
- * Not called by any generated code today apps currently hand-roll this same
361
- * transform against `auth.api.getSession({ headers: await headers() })` (see
362
- * `examples/starter-auth/lib/auth.ts`). Exported as a reusable helper for that
363
- * pattern; pass the caller's request headers (e.g. Next.js `await headers()`
364
- * in a Server Component/action) so a session cookie can actually be resolved.
591
+ * `auth` is typed structurally over just the one member this function reads
592
+ * `api.getSession` rather than a single concrete `Auth<Options>`
593
+ * instantiation, so it accepts an instance from either `createAuth()`
594
+ * overload: the widened `Auth<BetterAuthOptions>`, or the narrowed
595
+ * `Auth<ResolvedBetterAuthOptions<TPlugins>>` returned when a plugin tuple is
596
+ * passed. `TResolvedSession` is inferred from whatever `auth.api.getSession`
597
+ * actually returns (the default `{ session, user }` shape, or a
598
+ * `customSession` plugin's replaced shape) — it is not constrained, so no
599
+ * `any`/`unknown` is introduced at the call boundary.
365
600
  */
366
- export async function getSessionFromAuth(
367
- auth: ReturnType<typeof betterAuth>,
601
+ export async function getSessionFromAuth<TResolvedSession>(
602
+ auth: { api: { getSession: (args: { headers: Headers }) => Promise<TResolvedSession> } },
368
603
  sessionFields: string[],
369
604
  headers: Headers,
370
- ) {
371
- try {
372
- const session = await auth.api.getSession({ headers })
605
+ ): Promise<Session | null> {
606
+ const resolvedSession = await auth.api.getSession({ headers })
373
607
 
374
- if (!session?.user) {
375
- return null
376
- }
608
+ if (!resolvedSession) {
609
+ return null
610
+ }
377
611
 
378
- // Build session object with requested fields
379
- const result: Record<string, unknown> = {}
612
+ const resolvedSessionRecord = resolvedSession as Record<string, unknown>
613
+ const result: Record<string, unknown> = {}
380
614
 
381
- for (const field of sessionFields) {
382
- if (field === 'userId') {
383
- result.userId = session.user.id
384
- } else if (field in session.user) {
385
- result[field] = session.user[field as keyof typeof session.user]
386
- }
615
+ for (const field of sessionFields) {
616
+ const resolved = resolveSessionField(field, resolvedSessionRecord)
617
+ if (resolved.found) {
618
+ result[field] = resolved.value
619
+ } else {
620
+ warnUnresolvedSessionField(field, resolvedSessionRecord)
387
621
  }
388
-
389
- return result
390
- } catch {
391
- return null
392
622
  }
623
+
624
+ return result
393
625
  }
394
626
 
395
627
  export type { BetterAuthOptions }
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { list } from '@opensaas/stack-core'
7
- import { text, timestamp, checkbox, integer } from '@opensaas/stack-core/fields'
7
+ import { text, timestamp, checkbox, integer, bigInt } from '@opensaas/stack-core/fields'
8
8
  import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
9
9
 
10
10
  /**
@@ -12,24 +12,32 @@ 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
25
25
  input?: boolean
26
+ /**
27
+ * "If the field should be a bigint on the database instead of integer" —
28
+ * only meaningful alongside `type: 'number'`. Modelled here so the `number`
29
+ * branch below can read it; better-auth's rate limiter is the only shipped
30
+ * example (`lastRequest`, a millisecond epoch that overflows a 32-bit
31
+ * `Int`), but any plugin can declare it (issue #917).
32
+ */
33
+ bigint?: boolean
26
34
  }
27
35
 
28
36
  /**
29
37
  * Better Auth table schema structure
30
38
  */
31
39
  type BetterAuthTableSchema = {
32
- modelName: string
40
+ modelName?: string
33
41
  fields: Record<string, BetterAuthFieldAttribute>
34
42
  }
35
43
 
@@ -40,7 +48,7 @@ function convertField(
40
48
  fieldName: string,
41
49
  betterAuthField: BetterAuthFieldAttribute,
42
50
  ): FieldConfig | null {
43
- const { type, required, unique, defaultValue, references } = betterAuthField
51
+ const { type, required, unique, defaultValue, references, bigint } = betterAuthField
44
52
 
45
53
  // System fields are auto-generated by OpenSaaS
46
54
  if (fieldName === 'id' || fieldName === 'createdAt' || fieldName === 'updatedAt') {
@@ -64,6 +72,16 @@ function convertField(
64
72
  })
65
73
 
66
74
  case 'number':
75
+ // A millisecond epoch (e.g. Date.now()) overflows a 32-bit Int, so a
76
+ // field declaring `bigint: true` must generate a BigInt column instead
77
+ // (issue #917) — matching what deriveAuthLists produces for the same
78
+ // upstream declaration (e.g. the rate limiter's `lastRequest`).
79
+ if (bigint) {
80
+ return bigInt({
81
+ validation: { isRequired: required },
82
+ defaultValue: typeof defaultValue === 'number' ? defaultValue : undefined,
83
+ })
84
+ }
67
85
  return integer({
68
86
  validation: { isRequired: required },
69
87
  defaultValue: typeof defaultValue === 'number' ? defaultValue : undefined,
@@ -137,10 +155,11 @@ export function convertTableToList(
137
155
  }
138
156
 
139
157
  /**
140
- * Base better-auth model keys — the four tables the auth plugin's own model
141
- * config (`user`/`session`/`account`/`verification`) can remap via `modelName`.
158
+ * Base better-auth model keys — the tables the auth plugin's own model config
159
+ * (`user`/`session`/`account`/`verification`, plus `rateLimit` when
160
+ * `rateLimit.storage: 'database'` derives it) can remap via `modelName`.
142
161
  */
143
- type BaseAuthModelKey = 'user' | 'session' | 'account' | 'verification'
162
+ type BaseAuthModelKey = 'user' | 'session' | 'account' | 'verification' | 'rateLimit'
144
163
 
145
164
  /**
146
165
  * Resolved list key for each base better-auth model, as derived by
@@ -171,6 +190,8 @@ function resolveBaseModelKey(
171
190
  return baseModelKeys.account
172
191
  case 'verification':
173
192
  return baseModelKeys.verification
193
+ case 'ratelimit':
194
+ return baseModelKeys.rateLimit
174
195
  default:
175
196
  return undefined
176
197
  }
@@ -280,3 +280,76 @@ describe('adoptBetterAuthTables - clean-diff adoption (Auth lists ≠ app User)'
280
280
  expect(result.lists.AuthSession.fields.user.db?.foreignKey).toEqual({ map: 'user_id' })
281
281
  })
282
282
  })
283
+
284
+ describe('adoptBetterAuthTables - rateLimit adoption (issue #909)', () => {
285
+ it('omits rateLimit from the fragment by default', () => {
286
+ const fragment = adoptBetterAuthTables()
287
+
288
+ expect(fragment.rateLimit).toBeUndefined()
289
+ })
290
+
291
+ it('adds a prefixed, database-storage rateLimit fragment when opted in', () => {
292
+ const fragment = adoptBetterAuthTables({ rateLimit: true })
293
+
294
+ expect(fragment.rateLimit).toEqual({
295
+ enabled: true,
296
+ storage: 'database',
297
+ modelName: 'AuthRateLimit',
298
+ })
299
+ })
300
+
301
+ it('honours a custom schema/prefix on the rateLimit fragment like the other four models', () => {
302
+ const fragment = adoptBetterAuthTables({
303
+ rateLimit: true,
304
+ schema: 'identity',
305
+ modelNamePrefix: 'BA',
306
+ })
307
+
308
+ expect(fragment.rateLimit).toEqual({
309
+ enabled: true,
310
+ storage: 'database',
311
+ modelName: 'BARateLimit',
312
+ })
313
+ })
314
+
315
+ it('useBetterAuthTableNames sets the rateLimit tableName to the better-auth default', () => {
316
+ const fragment = adoptBetterAuthTables({ rateLimit: true, useBetterAuthTableNames: true })
317
+
318
+ expect(fragment.rateLimit).toEqual({
319
+ enabled: true,
320
+ storage: 'database',
321
+ modelName: 'AuthRateLimit',
322
+ tableName: 'rateLimit',
323
+ })
324
+ })
325
+
326
+ it('merges a rateLimit field column map when provided', () => {
327
+ const fragment = adoptBetterAuthTables({
328
+ rateLimit: true,
329
+ fields: { rateLimit: { key: 'limit_key' } },
330
+ })
331
+
332
+ expect(fragment.rateLimit).toEqual({
333
+ enabled: true,
334
+ storage: 'database',
335
+ modelName: 'AuthRateLimit',
336
+ fields: { key: 'limit_key' },
337
+ })
338
+ })
339
+
340
+ it('composes with authPlugin to actually derive the fifth Auth list', async () => {
341
+ const result = await generationConfig({
342
+ db: { provider: 'postgresql' },
343
+ plugins: [
344
+ authPlugin({
345
+ ...adoptBetterAuthTables({ rateLimit: true }),
346
+ emailAndPassword: { enabled: true },
347
+ }),
348
+ ],
349
+ lists: {},
350
+ })
351
+
352
+ expect(result.lists).toHaveProperty('AuthRateLimit')
353
+ expect(result.lists.AuthRateLimit.db?.schema).toBe('auth')
354
+ })
355
+ })