@opensaas/stack-auth 0.38.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 (48) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/dist/config/adopt-better-auth-tables.d.ts +23 -3
  4. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  5. package/dist/config/adopt-better-auth-tables.js +7 -2
  6. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  7. package/dist/config/derive-auth-lists.d.ts +6 -1
  8. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  9. package/dist/config/derive-auth-lists.js +63 -16
  10. package/dist/config/derive-auth-lists.js.map +1 -1
  11. package/dist/config/index.d.ts.map +1 -1
  12. package/dist/config/index.js +12 -5
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/config/plugin.d.ts.map +1 -1
  15. package/dist/config/plugin.js +6 -1
  16. package/dist/config/plugin.js.map +1 -1
  17. package/dist/config/types.d.ts +44 -2
  18. package/dist/config/types.d.ts.map +1 -1
  19. package/dist/server/get-session-from-auth.test.d.ts +2 -0
  20. package/dist/server/get-session-from-auth.test.d.ts.map +1 -0
  21. package/dist/server/get-session-from-auth.test.js +25 -0
  22. package/dist/server/get-session-from-auth.test.js.map +1 -0
  23. package/dist/server/index.d.ts +17 -2
  24. package/dist/server/index.d.ts.map +1 -1
  25. package/dist/server/index.js +28 -1
  26. package/dist/server/index.js.map +1 -1
  27. package/dist/server/schema-converter.d.ts +12 -3
  28. package/dist/server/schema-converter.d.ts.map +1 -1
  29. package/dist/server/schema-converter.js +14 -2
  30. package/dist/server/schema-converter.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/config/adopt-better-auth-tables.ts +31 -4
  33. package/src/config/derive-auth-lists.ts +82 -21
  34. package/src/config/index.ts +18 -5
  35. package/src/config/plugin.ts +6 -1
  36. package/src/config/types.ts +45 -2
  37. package/src/server/get-session-from-auth.test.ts +52 -0
  38. package/src/server/index.ts +35 -3
  39. package/src/server/schema-converter.ts +26 -5
  40. package/tests/adopt-better-auth-tables.test.ts +73 -0
  41. package/tests/config.test.ts +161 -0
  42. package/tests/derive-auth-lists.test.ts +104 -0
  43. package/tests/generated-fk-shape.test.ts +81 -0
  44. package/tests/plugin-schema-placement.test.ts +39 -0
  45. package/tests/rate-limit-e2e.test.ts +239 -0
  46. package/tests/schema-converter.test.ts +58 -0
  47. package/tests/server.test.ts +100 -0
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -203,6 +203,14 @@ export type AuthAccessConfig = {
203
203
  account?: ListConfig<any>['access']
204
204
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
205
205
  verification?: ListConfig<any>['access']
206
+ /**
207
+ * Access control for the `RateLimit` list — only meaningful when
208
+ * `rateLimit.storage: 'database'` derives it. Per ADR-0013 the list ships
209
+ * closed like the other four; grant access here (e.g. to inspect throttled
210
+ * keys in the Admin UI).
211
+ */
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
213
+ rateLimit?: ListConfig<any>['access']
206
214
  }
207
215
 
208
216
  export type AuthModelConfig = {
@@ -413,6 +421,22 @@ export type AuthConfig = {
413
421
  * Rate limiting configuration
414
422
  * Controls rate limiting for authentication endpoints
415
423
  *
424
+ * `storage` mirrors better-auth's own `rateLimit.storage` option
425
+ * (`'memory' | 'database' | 'secondary-storage'`, default `'memory'`). Set
426
+ * it to `'database'` to persist the limiter across restarts/instances — the
427
+ * plugin then derives a fifth `RateLimit` Auth list (per ADR-0007) so the
428
+ * required table exists in the generated Prisma schema, following the same
429
+ * adoption knobs (`modelName`/`fields`/`tableName`/`schema`) the other four
430
+ * models carry. Derivation keys off `storage` alone — `enabled: false` with
431
+ * `storage: 'database'` still produces the list, since better-auth still
432
+ * expects the table regardless of whether the limiter is currently active
433
+ * (`enabled` is routinely environment-driven; tying the schema to it would
434
+ * make dev/prod schemas differ).
435
+ *
436
+ * Setting `storage` via `betterAuthOptions.rateLimit.storage` is rejected —
437
+ * use this option instead, since it also has schema consequences the
438
+ * passthrough can't apply.
439
+ *
416
440
  * @example
417
441
  * ```typescript
418
442
  * // Disable rate limiting for testing
@@ -426,6 +450,12 @@ export type AuthConfig = {
426
450
  * window: 60, // 60 seconds
427
451
  * max: 100, // 100 requests per window
428
452
  * }
453
+ *
454
+ * // Persist the limiter in the database (derives a RateLimit list)
455
+ * rateLimit: {
456
+ * enabled: true,
457
+ * storage: 'database',
458
+ * }
429
459
  * ```
430
460
  */
431
461
  rateLimit?: {
@@ -440,7 +470,12 @@ export type AuthConfig = {
440
470
  * @default 100
441
471
  */
442
472
  max?: number
443
- }
473
+ /**
474
+ * Where better-auth persists the rate limiter.
475
+ * @default 'memory'
476
+ */
477
+ storage?: 'memory' | 'database' | 'secondary-storage'
478
+ } & AuthModelConfig
444
479
 
445
480
  /**
446
481
  * Escape hatch for better-auth options the stack doesn't model — typed as
@@ -498,7 +533,8 @@ export type NormalizedAuthModelConfig = {
498
533
  }
499
534
 
500
535
  /**
501
- * Resolved auth model configuration for all four better-auth models.
536
+ * Resolved auth model configuration for all four better-auth models, plus an
537
+ * optional fifth for the database-backed rate limiter.
502
538
  * Consumed by the Auth-list derivation and the runtime user-key resolution.
503
539
  */
504
540
  export type NormalizedAuthModels = {
@@ -506,6 +542,12 @@ export type NormalizedAuthModels = {
506
542
  session: NormalizedAuthModelConfig
507
543
  account: NormalizedAuthModelConfig
508
544
  verification: NormalizedAuthModelConfig
545
+ /**
546
+ * Present only when `rateLimit.storage === 'database'` — derives a fifth
547
+ * `RateLimit` Auth list. Absent (not `undefined`-valued) otherwise, so
548
+ * `Object.values(models)` never yields a model-less entry.
549
+ */
550
+ rateLimit?: NormalizedAuthModelConfig
509
551
  }
510
552
 
511
553
  /**
@@ -545,5 +587,6 @@ export type NormalizedAuthConfig = Required<
545
587
  enabled: boolean
546
588
  window?: number
547
589
  max?: number
590
+ storage?: 'memory' | 'database' | 'secondary-storage'
548
591
  }
549
592
  }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expectTypeOf } from 'vitest'
2
+ import type { OpenSaasConfig, AccessContext, Session } from '@opensaas/stack-core'
3
+ import { emailOTP } from 'better-auth/plugins'
4
+ import { createAuth, getSessionFromAuth } from './index.js'
5
+
6
+ // Type-level regression coverage for #906: `getSessionFromAuth` must accept
7
+ // an auth instance from either `createAuth()` overload — the widened
8
+ // `Auth<BetterAuthOptions>` returned with no plugin tuple, and the narrowed
9
+ // `Auth<ResolvedBetterAuthOptions<TPlugins>>` returned when one is passed —
10
+ // with no cast and no intermediate widening assignment. `Parameters<typeof
11
+ // createAuth>` on the overloaded export resolves against its last (generic)
12
+ // signature rather than the call-site-selected one, so each call shape is
13
+ // wrapped in its own ordinary function and read back via `typeof`, mirroring
14
+ // `build-better-auth-options.test.ts`.
15
+ type TestPlugins = [ReturnType<typeof emailOTP>]
16
+
17
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
18
+ function callWithNoPlugins(
19
+ config: OpenSaasConfig | Promise<OpenSaasConfig>,
20
+ context: AccessContext | Promise<AccessContext>,
21
+ ) {
22
+ return createAuth(config, context)
23
+ }
24
+
25
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
26
+ function callWithPlugins(
27
+ config: OpenSaasConfig | Promise<OpenSaasConfig>,
28
+ context: AccessContext | Promise<AccessContext>,
29
+ plugins: TestPlugins,
30
+ ) {
31
+ return createAuth(config, context, plugins)
32
+ }
33
+
34
+ type WidenedAuth = ReturnType<typeof callWithNoPlugins>
35
+ type NarrowedAuth = ReturnType<typeof callWithPlugins>
36
+
37
+ describe('getSessionFromAuth accepts either createAuth() overload (#906)', () => {
38
+ it('accepts the widened Auth<BetterAuthOptions> instance with no cast', () => {
39
+ expectTypeOf<WidenedAuth>().toExtend<Parameters<typeof getSessionFromAuth>[0]>()
40
+ })
41
+
42
+ it('accepts the narrowed, plugin-typed Auth instance with no cast', () => {
43
+ // This is the exact case #906 reported as a type error: a `createAuth`
44
+ // instance narrowed by a plugin tuple, passed straight into
45
+ // `getSessionFromAuth` without widening it back to `Auth<BetterAuthOptions>`.
46
+ expectTypeOf<NarrowedAuth>().toExtend<Parameters<typeof getSessionFromAuth>[0]>()
47
+ })
48
+
49
+ it('return type stays Promise<Session | null>, unchanged by the widened/narrowed instance', () => {
50
+ expectTypeOf(getSessionFromAuth).returns.toEqualTypeOf<Promise<Session | null>>()
51
+ })
52
+ })
@@ -111,6 +111,21 @@ function assertNoUnsupportedPassthroughKeys(betterAuthOptions: Record<string, un
111
111
  )
112
112
  }
113
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
+
114
129
  for (const model of MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH) {
115
130
  const modelOptions = betterAuthOptions[model]
116
131
  if (
@@ -330,12 +345,19 @@ export async function buildBetterAuthOptions<const TPlugins extends readonly Bet
330
345
  {} as Record<string, { clientId: string; clientSecret: string }>,
331
346
  ),
332
347
 
333
- // 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.
334
352
  rateLimit: authConfig.rateLimit
335
353
  ? {
336
354
  enabled: authConfig.rateLimit.enabled,
337
355
  window: authConfig.rateLimit.window,
338
356
  max: authConfig.rateLimit.max,
357
+ ...(authConfig.rateLimit.storage ? { storage: authConfig.rateLimit.storage } : {}),
358
+ ...(authConfig.models.rateLimit
359
+ ? toBetterAuthModelOptions(authConfig.models.rateLimit)
360
+ : {}),
339
361
  }
340
362
  : undefined,
341
363
 
@@ -565,9 +587,19 @@ function resolveSessionField(
565
587
  * await headers() })`. Exported as the single reusable implementation; pass
566
588
  * the caller's request headers (e.g. Next.js `await headers()` in a Server
567
589
  * Component/action) so a session cookie can actually be resolved.
590
+ *
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.
568
600
  */
569
- export async function getSessionFromAuth(
570
- auth: ReturnType<typeof betterAuth>,
601
+ export async function getSessionFromAuth<TResolvedSession>(
602
+ auth: { api: { getSession: (args: { headers: Headers }) => Promise<TResolvedSession> } },
571
603
  sessionFields: string[],
572
604
  headers: Headers,
573
605
  ): Promise<Session | null> {
@@ -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
  /**
@@ -23,6 +23,14 @@ type BetterAuthFieldAttribute = {
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
  /**
@@ -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
+ })
@@ -223,6 +223,82 @@ describe('normalizeAuthConfig', () => {
223
223
  expect(result.models.session.tableName).toBe('sessions')
224
224
  })
225
225
  })
226
+
227
+ describe('models.rateLimit (issue #909)', () => {
228
+ it('is absent when rateLimit is not configured', () => {
229
+ const result = normalizeAuthConfig({})
230
+
231
+ expect(result.models.rateLimit).toBeUndefined()
232
+ })
233
+
234
+ it('is absent when storage is "memory" or unset', () => {
235
+ expect(normalizeAuthConfig({ rateLimit: { enabled: true } }).models.rateLimit).toBeUndefined()
236
+ expect(
237
+ normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'memory' } }).models.rateLimit,
238
+ ).toBeUndefined()
239
+ })
240
+
241
+ it('is absent when storage is "secondary-storage"', () => {
242
+ expect(
243
+ normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'secondary-storage' } }).models
244
+ .rateLimit,
245
+ ).toBeUndefined()
246
+ })
247
+
248
+ it('is present with the default RateLimit model name when storage is "database"', () => {
249
+ const result = normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'database' } })
250
+
251
+ expect(result.models.rateLimit).toEqual({
252
+ modelName: 'RateLimit',
253
+ tableName: undefined,
254
+ fields: {},
255
+ })
256
+ })
257
+
258
+ it('is present even when enabled is false, since better-auth still expects the table', () => {
259
+ const result = normalizeAuthConfig({ rateLimit: { enabled: false, storage: 'database' } })
260
+
261
+ expect(result.models.rateLimit).toBeDefined()
262
+ expect(result.models.rateLimit?.modelName).toBe('RateLimit')
263
+ })
264
+
265
+ it('honours a custom modelName/tableName/fields/schema on the rateLimit model', () => {
266
+ const result = normalizeAuthConfig({
267
+ rateLimit: {
268
+ enabled: true,
269
+ storage: 'database',
270
+ modelName: 'AuthRateLimit',
271
+ tableName: 'rate_limit',
272
+ fields: { key: 'limit_key' },
273
+ schema: 'auth',
274
+ },
275
+ })
276
+
277
+ expect(result.models.rateLimit).toEqual({
278
+ modelName: 'AuthRateLimit',
279
+ tableName: 'rate_limit',
280
+ fields: { key: 'limit_key' },
281
+ schema: 'auth',
282
+ })
283
+ })
284
+
285
+ it('resolves the plugin-level schema default for the rateLimit model like the other four', () => {
286
+ const result = normalizeAuthConfig({
287
+ schema: 'auth',
288
+ rateLimit: { enabled: true, storage: 'database' },
289
+ })
290
+
291
+ expect(result.models.rateLimit?.schema).toBe('auth')
292
+ expect(result.models.user.schema).toBe('auth')
293
+ })
294
+
295
+ it('keeps storage on the normalized top-level rateLimit config', () => {
296
+ const result = normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'database' } })
297
+
298
+ expect(result.rateLimit?.storage).toBe('database')
299
+ expect(result.rateLimit?.enabled).toBe(true)
300
+ })
301
+ })
226
302
  })
227
303
 
228
304
  describe('authPlugin', () => {
@@ -585,4 +661,89 @@ describe('authPlugin', () => {
585
661
  expect(result.lists.User.access).toBe(extendAccess)
586
662
  })
587
663
  })
664
+
665
+ describe('RateLimit list (issue #909)', () => {
666
+ it('does not inject a RateLimit list when rateLimit is unconfigured', async () => {
667
+ const result = await config({
668
+ plugins: [authPlugin({})],
669
+ lists: {},
670
+ })
671
+
672
+ expect(result.lists).not.toHaveProperty('RateLimit')
673
+ })
674
+
675
+ it('does not inject a RateLimit list for storage "memory" or "secondary-storage"', async () => {
676
+ const memory = await config({
677
+ plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'memory' } })],
678
+ lists: {},
679
+ })
680
+ expect(memory.lists).not.toHaveProperty('RateLimit')
681
+
682
+ const secondary = await config({
683
+ plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'secondary-storage' } })],
684
+ lists: {},
685
+ })
686
+ expect(secondary.lists).not.toHaveProperty('RateLimit')
687
+ })
688
+
689
+ it('injects a RateLimit list when storage is "database"', async () => {
690
+ const result = await config({
691
+ plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'database' } })],
692
+ lists: {},
693
+ })
694
+
695
+ const rateLimit = result.lists.RateLimit
696
+ expect(rateLimit).toBeDefined()
697
+ expect(rateLimit.fields).toHaveProperty('key')
698
+ expect(rateLimit.fields).toHaveProperty('count')
699
+ expect(rateLimit.fields).toHaveProperty('lastRequest')
700
+ })
701
+
702
+ it('injects a RateLimit list even when enabled is false, since better-auth still expects the table', async () => {
703
+ const result = await config({
704
+ plugins: [authPlugin({ rateLimit: { enabled: false, storage: 'database' } })],
705
+ lists: {},
706
+ })
707
+
708
+ expect(result.lists.RateLimit).toBeDefined()
709
+ })
710
+
711
+ it('ships the RateLimit list closed by default (ADR-0013)', async () => {
712
+ const result = await config({
713
+ plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'database' } })],
714
+ lists: {},
715
+ })
716
+
717
+ expect(result.lists.RateLimit.access).toBeUndefined()
718
+ })
719
+
720
+ it('applies access.rateLimit to the derived list', async () => {
721
+ const rateLimitQuery = () => true
722
+ const result = await config({
723
+ plugins: [
724
+ authPlugin({
725
+ rateLimit: { enabled: true, storage: 'database' },
726
+ access: { rateLimit: { operation: { query: rateLimitQuery } } },
727
+ }),
728
+ ],
729
+ lists: {},
730
+ })
731
+
732
+ expect(result.lists.RateLimit.access?.operation?.query).toBe(rateLimitQuery)
733
+ })
734
+
735
+ it('respects a custom modelName on the rateLimit config', async () => {
736
+ const result = await config({
737
+ plugins: [
738
+ authPlugin({
739
+ rateLimit: { enabled: true, storage: 'database', modelName: 'AuthRateLimit' },
740
+ }),
741
+ ],
742
+ lists: {},
743
+ })
744
+
745
+ expect(result.lists).toHaveProperty('AuthRateLimit')
746
+ expect(result.lists).not.toHaveProperty('RateLimit')
747
+ })
748
+ })
588
749
  })
@@ -344,6 +344,110 @@ describe('deriveAuthLists - schema placement', () => {
344
344
  })
345
345
  })
346
346
 
347
+ describe('deriveAuthLists - RateLimit list (rateLimit.storage === "database")', () => {
348
+ it('is absent when no rateLimit model is supplied', () => {
349
+ const { keys, lists } = deriveAuthLists(defaultModels)
350
+
351
+ expect(keys.rateLimit).toBeUndefined()
352
+ expect(lists.RateLimit).toBeUndefined()
353
+ expect(Object.keys(lists).sort()).toEqual(['Account', 'Session', 'User', 'Verification'])
354
+ })
355
+
356
+ it('derives a RateLimit list keyed by the default model name when present', () => {
357
+ const models: NormalizedAuthModels = {
358
+ ...defaultModels,
359
+ rateLimit: { modelName: 'RateLimit', fields: {} },
360
+ }
361
+
362
+ const { keys, lists } = deriveAuthLists(models)
363
+
364
+ expect(keys.rateLimit).toBe('RateLimit')
365
+ expect(Object.keys(lists).sort()).toEqual([
366
+ 'Account',
367
+ 'RateLimit',
368
+ 'Session',
369
+ 'User',
370
+ 'Verification',
371
+ ])
372
+ })
373
+
374
+ it('mirrors better-auth’s rateLimit table shape: key/count/lastRequest, all required, no defaults', () => {
375
+ const models: NormalizedAuthModels = {
376
+ ...defaultModels,
377
+ rateLimit: { modelName: 'RateLimit', fields: {} },
378
+ }
379
+
380
+ const { lists } = deriveAuthLists(models)
381
+ const rateLimit = lists.RateLimit
382
+
383
+ expect(rateLimit.fields.key.type).toBe('text')
384
+ expect(rateLimit.fields.key.isIndexed).toBe('unique')
385
+ expect(rateLimit.fields.key.validation?.isRequired).toBe(true)
386
+ expect(rateLimit.fields.key.defaultValue).toBeUndefined()
387
+
388
+ expect(rateLimit.fields.count.type).toBe('integer')
389
+ expect(rateLimit.fields.count.validation?.isRequired).toBe(true)
390
+ expect(rateLimit.fields.count.db?.isNullable).toBe(false)
391
+ expect(rateLimit.fields.count.defaultValue).toBeUndefined()
392
+
393
+ expect(rateLimit.fields.lastRequest.type).toBe('bigInt')
394
+ expect(rateLimit.fields.lastRequest.validation?.isRequired).toBe(true)
395
+ expect(rateLimit.fields.lastRequest.db?.isNullable).toBe(false)
396
+ expect(rateLimit.fields.lastRequest.defaultValue).toBeUndefined()
397
+
398
+ // Exactly these three fields — no createdAt/updatedAt columns on this list.
399
+ expect(Object.keys(rateLimit.fields).sort()).toEqual(['count', 'key', 'lastRequest'])
400
+ })
401
+
402
+ it('does not opt into auto-timestamps, unlike the other four Auth lists', () => {
403
+ const models: NormalizedAuthModels = {
404
+ ...defaultModels,
405
+ rateLimit: { modelName: 'RateLimit', fields: {} },
406
+ }
407
+
408
+ const { lists } = deriveAuthLists(models)
409
+
410
+ expect(lists.RateLimit.db?.timestamps).toBeUndefined()
411
+ expect(lists.User.db?.timestamps).toBe(true)
412
+ })
413
+
414
+ it('applies a custom modelName, tableName, field column maps, and schema like the other models', () => {
415
+ const models: NormalizedAuthModels = {
416
+ ...defaultModels,
417
+ rateLimit: {
418
+ modelName: 'AuthRateLimit',
419
+ tableName: 'rate_limit',
420
+ fields: { key: 'limit_key', count: 'hit_count', lastRequest: 'last_hit_at' },
421
+ schema: 'auth',
422
+ },
423
+ }
424
+
425
+ const { keys, lists } = deriveAuthLists(models)
426
+
427
+ expect(keys.rateLimit).toBe('AuthRateLimit')
428
+ const rateLimit = lists.AuthRateLimit
429
+ expect(rateLimit.db?.map).toBe('rate_limit')
430
+ expect(rateLimit.db?.schema).toBe('auth')
431
+ expect(rateLimit.fields.key.db?.map).toBe('limit_key')
432
+ expect(rateLimit.fields.count.db?.map).toBe('hit_count')
433
+ expect(rateLimit.fields.lastRequest.db?.map).toBe('last_hit_at')
434
+ })
435
+
436
+ it('ships closed (no access) unless accessConfig.rateLimit is supplied', () => {
437
+ const models: NormalizedAuthModels = {
438
+ ...defaultModels,
439
+ rateLimit: { modelName: 'RateLimit', fields: {} },
440
+ }
441
+
442
+ const { lists: closed } = deriveAuthLists(models)
443
+ expect(closed.RateLimit.access).toBeUndefined()
444
+
445
+ const rateLimitAccess = { operation: { query: () => true } }
446
+ const { lists: open } = deriveAuthLists(models, {}, { rateLimit: rateLimitAccess })
447
+ expect(open.RateLimit.access).toBe(rateLimitAccess)
448
+ })
449
+ })
450
+
347
451
  describe('deriveAuthLists - extendUserList', () => {
348
452
  it('adds custom fields to the derived user list', () => {
349
453
  const { lists } = deriveAuthLists(