@opensaas/stack-auth 0.39.2 → 0.40.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 (104) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +151 -0
  3. package/CLAUDE.md +140 -22
  4. package/dist/client/index.d.ts +0 -16
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/config/adopt-better-auth-tables.d.ts +4 -49
  7. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  8. package/dist/config/adopt-better-auth-tables.js +4 -51
  9. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  10. package/dist/config/derive-auth-lists.d.ts +42 -37
  11. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  12. package/dist/config/derive-auth-lists.js +579 -223
  13. package/dist/config/derive-auth-lists.js.map +1 -1
  14. package/dist/config/index.d.ts +0 -3
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +4 -34
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/config/plugin.d.ts.map +1 -1
  19. package/dist/config/plugin.js +20 -90
  20. package/dist/config/plugin.js.map +1 -1
  21. package/dist/config/types.d.ts +89 -67
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/index.d.ts +1 -9
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -13
  26. package/dist/index.js.map +1 -1
  27. package/dist/lists/index.d.ts +9 -5
  28. package/dist/lists/index.d.ts.map +1 -1
  29. package/dist/lists/index.js +9 -6
  30. package/dist/lists/index.js.map +1 -1
  31. package/dist/mcp/better-auth.d.ts +6 -19
  32. package/dist/mcp/better-auth.d.ts.map +1 -1
  33. package/dist/mcp/better-auth.js +6 -20
  34. package/dist/mcp/better-auth.js.map +1 -1
  35. package/dist/plugins/index.d.ts +4 -4
  36. package/dist/plugins/index.d.ts.map +1 -1
  37. package/dist/plugins/index.js +4 -4
  38. package/dist/plugins/index.js.map +1 -1
  39. package/dist/runtime/types.d.ts +2 -9
  40. package/dist/runtime/types.d.ts.map +1 -1
  41. package/dist/runtime/types.js +0 -4
  42. package/dist/runtime/types.js.map +1 -1
  43. package/dist/server/index.d.ts +5 -12
  44. package/dist/server/index.d.ts.map +1 -1
  45. package/dist/server/index.js +14 -62
  46. package/dist/server/index.js.map +1 -1
  47. package/dist/ui/components/ForgotPasswordForm.d.ts +0 -3
  48. package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -1
  49. package/dist/ui/components/ForgotPasswordForm.js +0 -3
  50. package/dist/ui/components/ForgotPasswordForm.js.map +1 -1
  51. package/dist/ui/components/ResetPasswordForm.d.ts +0 -3
  52. package/dist/ui/components/ResetPasswordForm.d.ts.map +1 -1
  53. package/dist/ui/components/ResetPasswordForm.js +0 -5
  54. package/dist/ui/components/ResetPasswordForm.js.map +1 -1
  55. package/dist/ui/components/SignInForm.d.ts +0 -3
  56. package/dist/ui/components/SignInForm.d.ts.map +1 -1
  57. package/dist/ui/components/SignInForm.js +0 -4
  58. package/dist/ui/components/SignInForm.js.map +1 -1
  59. package/dist/ui/components/SignUpForm.d.ts +0 -3
  60. package/dist/ui/components/SignUpForm.d.ts.map +1 -1
  61. package/dist/ui/components/SignUpForm.js +0 -5
  62. package/dist/ui/components/SignUpForm.js.map +1 -1
  63. package/dist/ui/lib/clean-error-message.d.ts +3 -6
  64. package/dist/ui/lib/clean-error-message.d.ts.map +1 -1
  65. package/dist/ui/lib/clean-error-message.js +3 -6
  66. package/dist/ui/lib/clean-error-message.js.map +1 -1
  67. package/dist/ui/types.d.ts +2 -15
  68. package/dist/ui/types.d.ts.map +1 -1
  69. package/dist/ui/types.js +2 -7
  70. package/dist/ui/types.js.map +1 -1
  71. package/package.json +12 -5
  72. package/src/client/index.ts +0 -17
  73. package/src/config/adopt-better-auth-tables.ts +4 -51
  74. package/src/config/derive-auth-lists.ts +685 -252
  75. package/src/config/index.ts +4 -34
  76. package/src/config/plugin.ts +21 -91
  77. package/src/config/types.ts +90 -67
  78. package/src/index.ts +3 -14
  79. package/src/lists/index.ts +17 -5
  80. package/src/mcp/better-auth.ts +6 -25
  81. package/src/plugins/index.ts +4 -5
  82. package/src/runtime/types.ts +2 -10
  83. package/src/server/index.ts +17 -70
  84. package/src/ui/components/ForgotPasswordForm.tsx +0 -3
  85. package/src/ui/components/ResetPasswordForm.tsx +0 -5
  86. package/src/ui/components/SignInForm.tsx +0 -4
  87. package/src/ui/components/SignUpForm.tsx +0 -5
  88. package/src/ui/lib/clean-error-message.ts +3 -6
  89. package/src/ui/types.ts +2 -15
  90. package/tests/auth-lists-drift.test.ts +480 -0
  91. package/tests/config.test.ts +3 -0
  92. package/tests/credential-field-read-deny-e2e.test.ts +295 -0
  93. package/tests/derive-auth-lists.test.ts +263 -6
  94. package/tests/generated-fk-shape.test.ts +432 -9
  95. package/tests/mcp-oauth-cascade-e2e.test.ts +267 -0
  96. package/tests/plugin-derived-keys.test.ts +39 -0
  97. package/tests/plugin-table-derivation.test.ts +270 -0
  98. package/tsconfig.tsbuildinfo +1 -1
  99. package/dist/server/schema-converter.d.ts +0 -81
  100. package/dist/server/schema-converter.d.ts.map +0 -1
  101. package/dist/server/schema-converter.js +0 -157
  102. package/dist/server/schema-converter.js.map +0 -1
  103. package/src/server/schema-converter.ts +0 -243
  104. package/tests/schema-converter.test.ts +0 -399
@@ -0,0 +1,480 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getAuthTables } from 'better-auth/db'
3
+ import { mcp } from '@better-auth/mcp'
4
+ import type { BetterAuthOptions } from 'better-auth'
5
+ import type { DBFieldAttribute } from 'better-auth/db'
6
+ import type { FieldConfig } from '@opensaas/stack-core'
7
+ import type { RelationshipField } from '@opensaas/stack-core/fields'
8
+ import { deriveAuthLists } from '../src/config/derive-auth-lists.js'
9
+ import type { NormalizedAuthModels } from '../src/config/types.js'
10
+
11
+ /**
12
+ * Drift detector for issue #988: reads better-auth's own resolved table
13
+ * definitions (`getAuthTables`) and compares them against `deriveAuthLists`'
14
+ * output — column name, index presence, uniqueness, field presence,
15
+ * requiredness, references, scalar type, and static default values — so an
16
+ * upstream schema change that the hand-written derivation doesn't mirror
17
+ * fails this suite instead of waiting for a human to notice (as
18
+ * #921/#935/#937/#981/#986 all were).
19
+ *
20
+ * Deliberately out of scope (see the issue body): fields the stack adds that
21
+ * better-auth doesn't declare (timestamps, access blocks, extendUserList
22
+ * merging), constraint *names* (#921), and `returned: false` (a serialization
23
+ * contract, not a schema fact).
24
+ */
25
+
26
+ type Divergence = {
27
+ model: string
28
+ field: string
29
+ dimension: string
30
+ detail: string
31
+ }
32
+
33
+ /**
34
+ * better-auth models its foreign keys as a plain scalar field carrying
35
+ * `references`; the derived lists model the same column as a `relationship()`
36
+ * field under a different key. This is the map between the two per model.
37
+ */
38
+ const FK_BRIDGE: Record<string, Record<string, string>> = {
39
+ session: { userId: 'user' },
40
+ account: { userId: 'user' },
41
+ // The MCP plugin's OAuth tables (issue #992) — `clientId`/`resourceId` are
42
+ // deliberately NOT bridged here: they reference oauthClient.clientId /
43
+ // oauthResource.identifier, not an `id`, so they stay plain scalar columns
44
+ // and are compared via `compareScalarField`.
45
+ oauthClient: { userId: 'user' },
46
+ oauthRefreshToken: { userId: 'user', sessionId: 'session' },
47
+ oauthAccessToken: { userId: 'user', sessionId: 'session', refreshId: 'refresh' },
48
+ oauthConsent: { userId: 'user' },
49
+ }
50
+
51
+ /** better-auth writes these on every row; the derived lists opt in via list-level `db.timestamps` rather than a per-field entry. */
52
+ const TIMESTAMP_FIELDS = new Set(['createdAt', 'updatedAt'])
53
+
54
+ function normalizeOnDelete(action: string): string {
55
+ return action.replace(/\s+/g, '').toLowerCase()
56
+ }
57
+
58
+ /**
59
+ * The Prisma scalar type a correctly-derived field should carry for a given
60
+ * better-auth field type. `undefined` means "not modeled by any field in
61
+ * these lists" (json/array types don't appear on user/session/account/
62
+ * verification/rateLimit) — the caller skips the comparison in that case
63
+ * rather than treating it as a mismatch.
64
+ */
65
+ function expectedPrismaType(upstream: DBFieldAttribute): string | undefined {
66
+ switch (upstream.type) {
67
+ case 'string':
68
+ return 'String'
69
+ case 'boolean':
70
+ return 'Boolean'
71
+ case 'date':
72
+ return 'DateTime'
73
+ case 'number':
74
+ return upstream.bigint ? 'BigInt' : 'Int'
75
+ default:
76
+ return undefined
77
+ }
78
+ }
79
+
80
+ /** Narrows a derived list's field to a `RelationshipField` — the shape the FK-bridged comparison reads. */
81
+ function isRelationshipField(field: FieldConfig): field is RelationshipField {
82
+ return field.type === 'relationship'
83
+ }
84
+
85
+ function compareScalarField(
86
+ model: string,
87
+ fieldKey: string,
88
+ upstream: DBFieldAttribute,
89
+ derived: FieldConfig | undefined,
90
+ divergences: Divergence[],
91
+ ): void {
92
+ if (!derived || !derived.getPrismaType) {
93
+ divergences.push({
94
+ model,
95
+ field: fieldKey,
96
+ dimension: 'field presence',
97
+ detail: `better-auth declares '${fieldKey}' but the derived list has no matching field`,
98
+ })
99
+ return
100
+ }
101
+
102
+ const prismaType = derived.getPrismaType(fieldKey)
103
+ const isNullable = prismaType.modifiers?.startsWith('?') ?? false
104
+ const isUnique = prismaType.modifiers?.includes('@unique') ?? false
105
+ const isIndexed = prismaType.index === true
106
+
107
+ // Base models always declare an explicit `fieldName`; a plugin table
108
+ // doesn't have to (`getAuthTables` merges plugin schema fields verbatim,
109
+ // with no per-field defaulting) — an unset `fieldName` means the column IS
110
+ // the field key, same as `scalarFieldDb` in `derive-auth-lists.ts` assumes.
111
+ const upstreamColumn = upstream.fieldName ?? fieldKey
112
+ const derivedColumn = derived.db?.map ?? fieldKey
113
+ if (derivedColumn !== upstreamColumn) {
114
+ divergences.push({
115
+ model,
116
+ field: fieldKey,
117
+ dimension: 'column name',
118
+ detail: `better-auth column "${upstreamColumn}" vs derived map "${derivedColumn}"`,
119
+ })
120
+ }
121
+
122
+ const upstreamRequired = upstream.required ?? true
123
+ if (isNullable === upstreamRequired) {
124
+ divergences.push({
125
+ model,
126
+ field: fieldKey,
127
+ dimension: 'requiredness',
128
+ detail: `better-auth required=${upstreamRequired} vs derived nullable=${isNullable}`,
129
+ })
130
+ }
131
+
132
+ const upstreamIndex = upstream.index === true
133
+ if (isIndexed !== upstreamIndex) {
134
+ divergences.push({
135
+ model,
136
+ field: fieldKey,
137
+ dimension: 'index presence',
138
+ detail: `better-auth index=${upstreamIndex} vs derived isIndexed=true is ${isIndexed}`,
139
+ })
140
+ }
141
+
142
+ const upstreamUnique = upstream.unique === true
143
+ if (isUnique !== upstreamUnique) {
144
+ divergences.push({
145
+ model,
146
+ field: fieldKey,
147
+ dimension: 'uniqueness',
148
+ detail: `better-auth unique=${upstreamUnique} vs derived isIndexed='unique' is ${isUnique}`,
149
+ })
150
+ }
151
+
152
+ const expectedType = expectedPrismaType(upstream)
153
+ if (expectedType !== undefined && prismaType.type !== expectedType) {
154
+ divergences.push({
155
+ model,
156
+ field: fieldKey,
157
+ dimension: 'scalar type',
158
+ detail: `better-auth type "${upstream.type}"${upstream.bigint ? ' (bigint)' : ''} expects Prisma "${expectedType}" vs derived "${prismaType.type}"`,
159
+ })
160
+ }
161
+
162
+ // Only a static, non-function default on a modeled type is comparable —
163
+ // better-auth's function-valued defaults (e.g. `createdAt: () => new
164
+ // Date()`) are applied at write time, not modeled as a derived field's own
165
+ // `defaultValue`; a type this derivation doesn't model at all (`json`,
166
+ // `string[]`, ...) falls back to `text()` (see `expectedType` above), which
167
+ // can't faithfully carry that type's own default either.
168
+ if (
169
+ expectedType !== undefined &&
170
+ typeof upstream.defaultValue !== 'function' &&
171
+ upstream.defaultValue !== undefined
172
+ ) {
173
+ if (derived.defaultValue !== upstream.defaultValue) {
174
+ divergences.push({
175
+ model,
176
+ field: fieldKey,
177
+ dimension: 'default value',
178
+ detail: `better-auth defaultValue=${JSON.stringify(upstream.defaultValue)} vs derived defaultValue=${JSON.stringify(derived.defaultValue)}`,
179
+ })
180
+ }
181
+ }
182
+ }
183
+
184
+ function compareForeignKeyField(
185
+ model: string,
186
+ upstreamFieldKey: string,
187
+ upstream: DBFieldAttribute,
188
+ derived: RelationshipField | undefined,
189
+ listKeys: Record<string, string>,
190
+ divergences: Divergence[],
191
+ ): void {
192
+ if (!derived) {
193
+ divergences.push({
194
+ model,
195
+ field: upstreamFieldKey,
196
+ dimension: 'field presence',
197
+ detail: `better-auth declares '${upstreamFieldKey}' but the derived relationship is missing`,
198
+ })
199
+ return
200
+ }
201
+
202
+ // See the same `fieldName ?? fieldKey` note in `compareScalarField` above.
203
+ const upstreamColumn = upstream.fieldName ?? upstreamFieldKey
204
+ const foreignKey = derived.db?.foreignKey
205
+ const derivedColumn = typeof foreignKey === 'object' ? foreignKey.map : undefined
206
+ if (derivedColumn !== upstreamColumn) {
207
+ divergences.push({
208
+ model,
209
+ field: upstreamFieldKey,
210
+ dimension: 'column name',
211
+ detail: `better-auth column "${upstreamColumn}" vs derived FK map "${derivedColumn}"`,
212
+ })
213
+ }
214
+
215
+ const isIndexed = derived.isIndexed === true
216
+ const upstreamIndex = upstream.index === true
217
+ if (isIndexed !== upstreamIndex) {
218
+ divergences.push({
219
+ model,
220
+ field: upstreamFieldKey,
221
+ dimension: 'index presence',
222
+ detail: `better-auth index=${upstreamIndex} vs derived isIndexed=true is ${isIndexed}`,
223
+ })
224
+ }
225
+
226
+ const isUnique = derived.isIndexed === 'unique'
227
+ const upstreamUnique = upstream.unique === true
228
+ if (isUnique !== upstreamUnique) {
229
+ divergences.push({
230
+ model,
231
+ field: upstreamFieldKey,
232
+ dimension: 'uniqueness',
233
+ detail: `better-auth unique=${upstreamUnique} vs derived isIndexed='unique' is ${isUnique}`,
234
+ })
235
+ }
236
+
237
+ const isNullable = derived.db?.isNullable ?? true
238
+ const derivedRequired = !isNullable
239
+ const upstreamRequired = upstream.required ?? true
240
+ if (derivedRequired !== upstreamRequired) {
241
+ divergences.push({
242
+ model,
243
+ field: upstreamFieldKey,
244
+ dimension: 'requiredness',
245
+ detail: `better-auth required=${upstreamRequired} vs derived required=${derivedRequired}`,
246
+ })
247
+ }
248
+
249
+ const references = upstream.references
250
+ if (references) {
251
+ const [targetListKey] = derived.ref.split('.')
252
+ const expectedListKey = listKeys[references.model]
253
+ if (targetListKey !== expectedListKey) {
254
+ divergences.push({
255
+ model,
256
+ field: upstreamFieldKey,
257
+ dimension: 'references',
258
+ detail: `better-auth references model "${references.model}" (list "${expectedListKey}") vs derived ref targets "${targetListKey}"`,
259
+ })
260
+ }
261
+
262
+ const extend = derived.db?.extendPrismaSchema
263
+ const relationLine = extend
264
+ ? extend({ relationLine: '@relation(fields: [x], references: [id])' }).relationLine
265
+ : ''
266
+ const derivedOnDelete = /onDelete:\s*(\w+)/.exec(relationLine)?.[1] ?? ''
267
+ const upstreamOnDelete = references.onDelete ?? 'cascade'
268
+ if (normalizeOnDelete(derivedOnDelete) !== normalizeOnDelete(upstreamOnDelete)) {
269
+ divergences.push({
270
+ model,
271
+ field: upstreamFieldKey,
272
+ dimension: 'references',
273
+ detail: `better-auth onDelete="${upstreamOnDelete}" vs derived relation line "${relationLine}"`,
274
+ })
275
+ }
276
+ }
277
+ }
278
+
279
+ function compareModel(
280
+ model: string,
281
+ upstreamFields: Record<string, DBFieldAttribute>,
282
+ derivedFields: Record<string, FieldConfig>,
283
+ listKeys: Record<string, string>,
284
+ divergences: Divergence[],
285
+ ): void {
286
+ const bridges = FK_BRIDGE[model] ?? {}
287
+
288
+ for (const [fieldKey, upstream] of Object.entries(upstreamFields)) {
289
+ if (TIMESTAMP_FIELDS.has(fieldKey)) continue
290
+
291
+ const bridgedKey = bridges[fieldKey]
292
+ if (bridgedKey) {
293
+ const candidate = derivedFields[bridgedKey]
294
+ const relationshipField = candidate && isRelationshipField(candidate) ? candidate : undefined
295
+ compareForeignKeyField(model, fieldKey, upstream, relationshipField, listKeys, divergences)
296
+ } else {
297
+ compareScalarField(model, fieldKey, upstream, derivedFields[fieldKey], divergences)
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Known, tracked divergences the comparison above would otherwise fail on.
304
+ * Each entry must name the field, dimension, and issue tracking the fix.
305
+ * When the fix lands, remove the entry — an entry that no longer matches a
306
+ * real divergence fails `no stale exceptions` below, so this list can't rot
307
+ * into a permanent silencer.
308
+ */
309
+ const KNOWN_EXCEPTIONS: Divergence[] = []
310
+
311
+ function exceptionKey(d: Divergence): string {
312
+ return `${d.model}.${d.field}:${d.dimension}`
313
+ }
314
+
315
+ describe('derived auth lists match better-auth’s own table definitions (issue #988)', () => {
316
+ const defaultModels: NormalizedAuthModels = {
317
+ user: { modelName: 'User', fields: {} },
318
+ session: { modelName: 'Session', fields: {} },
319
+ account: { modelName: 'Account', fields: {} },
320
+ verification: { modelName: 'Verification', fields: {} },
321
+ }
322
+
323
+ const defaultOptions: BetterAuthOptions = {}
324
+ const upstreamTables = getAuthTables(defaultOptions)
325
+ const { keys, lists } = deriveAuthLists(defaultModels)
326
+
327
+ const divergences: Divergence[] = []
328
+ for (const model of ['user', 'session', 'account', 'verification'] as const) {
329
+ const listKey = keys[model]
330
+ compareModel(model, upstreamTables[model].fields, lists[listKey].fields, keys, divergences)
331
+ }
332
+
333
+ const unexpected = divergences.filter(
334
+ (d) => !KNOWN_EXCEPTIONS.some((known) => exceptionKey(known) === exceptionKey(d)),
335
+ )
336
+ const staleExceptions = KNOWN_EXCEPTIONS.filter(
337
+ (known) => !divergences.some((d) => exceptionKey(d) === exceptionKey(known)),
338
+ )
339
+
340
+ it('has no unexpected divergences from better-auth’s user/session/account/verification tables', () => {
341
+ expect(unexpected, JSON.stringify(unexpected, null, 2)).toEqual([])
342
+ })
343
+
344
+ it('has no stale exception-list entries (fixed drift must be removed from KNOWN_EXCEPTIONS)', () => {
345
+ expect(staleExceptions, JSON.stringify(staleExceptions, null, 2)).toEqual([])
346
+ })
347
+
348
+ it('detects a real regression: removing isIndexed from Session.user', () => {
349
+ const mutated = deriveAuthLists(defaultModels)
350
+ mutated.lists[mutated.keys.session].fields.user.isIndexed = false
351
+
352
+ const regressionDivergences: Divergence[] = []
353
+ compareModel(
354
+ 'session',
355
+ upstreamTables.session.fields,
356
+ mutated.lists[mutated.keys.session].fields,
357
+ mutated.keys,
358
+ regressionDivergences,
359
+ )
360
+
361
+ expect(regressionDivergences).toContainEqual(
362
+ expect.objectContaining({ model: 'session', field: 'userId', dimension: 'index presence' }),
363
+ )
364
+ })
365
+
366
+ it('detects a real regression: changing the userId FK column map', () => {
367
+ const mutated = deriveAuthLists(defaultModels)
368
+ mutated.lists[mutated.keys.account].fields.user.db.foreignKey = { map: 'wrong_column' }
369
+
370
+ const regressionDivergences: Divergence[] = []
371
+ compareModel(
372
+ 'account',
373
+ upstreamTables.account.fields,
374
+ mutated.lists[mutated.keys.account].fields,
375
+ mutated.keys,
376
+ regressionDivergences,
377
+ )
378
+
379
+ expect(regressionDivergences).toContainEqual(
380
+ expect.objectContaining({ model: 'account', field: 'userId', dimension: 'column name' }),
381
+ )
382
+ })
383
+
384
+ it('detects a real regression: a field upstream declares going missing from the derived list', () => {
385
+ const mutated = deriveAuthLists(defaultModels)
386
+ delete mutated.lists[mutated.keys.account].fields.scope
387
+
388
+ const regressionDivergences: Divergence[] = []
389
+ compareModel(
390
+ 'account',
391
+ upstreamTables.account.fields,
392
+ mutated.lists[mutated.keys.account].fields,
393
+ mutated.keys,
394
+ regressionDivergences,
395
+ )
396
+
397
+ expect(regressionDivergences).toContainEqual(
398
+ expect.objectContaining({ model: 'account', field: 'scope', dimension: 'field presence' }),
399
+ )
400
+ })
401
+ })
402
+
403
+ describe('derived RateLimit list matches better-auth’s database-backed rateLimit table (issue #988)', () => {
404
+ const models: NormalizedAuthModels = {
405
+ user: { modelName: 'User', fields: {} },
406
+ session: { modelName: 'Session', fields: {} },
407
+ account: { modelName: 'Account', fields: {} },
408
+ verification: { modelName: 'Verification', fields: {} },
409
+ rateLimit: { modelName: 'RateLimit', fields: {} },
410
+ }
411
+
412
+ const rateLimitOptions: BetterAuthOptions = { rateLimit: { storage: 'database' } }
413
+ const upstreamTables = getAuthTables(rateLimitOptions)
414
+ const { keys, lists } = deriveAuthLists(models)
415
+
416
+ it('is present upstream only when rateLimit.storage is "database"', () => {
417
+ expect(upstreamTables.rateLimit).toBeDefined()
418
+ expect(keys.rateLimit).toBe('RateLimit')
419
+ })
420
+
421
+ it('has no divergences on key/count/lastRequest', () => {
422
+ const divergences: Divergence[] = []
423
+ compareModel(
424
+ 'rateLimit',
425
+ upstreamTables.rateLimit.fields,
426
+ lists.RateLimit.fields,
427
+ keys,
428
+ divergences,
429
+ )
430
+
431
+ expect(divergences, JSON.stringify(divergences, null, 2)).toEqual([])
432
+ })
433
+ })
434
+
435
+ describe('derived better-auth plugin tables match better-auth’s own table definitions (issue #992)', () => {
436
+ const defaultModels: NormalizedAuthModels = {
437
+ user: { modelName: 'User', fields: {} },
438
+ session: { modelName: 'Session', fields: {} },
439
+ account: { modelName: 'Account', fields: {} },
440
+ verification: { modelName: 'Verification', fields: {} },
441
+ }
442
+
443
+ const plugin = mcp({
444
+ loginPage: '/sign-in',
445
+ consentPage: '/consent',
446
+ resource: 'https://example.com/mcp',
447
+ })
448
+ const upstreamTables = getAuthTables({ plugins: [plugin] })
449
+ const { keys, lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
450
+ const pluginModelToListKey: Record<string, string> = {
451
+ oauthClient: 'OauthClient',
452
+ oauthClientAssertion: 'OauthClientAssertion',
453
+ oauthClientResource: 'OauthClientResource',
454
+ oauthAccessToken: 'OauthAccessToken',
455
+ oauthRefreshToken: 'OauthRefreshToken',
456
+ oauthResource: 'OauthResource',
457
+ oauthConsent: 'OauthConsent',
458
+ }
459
+ const listKeys = { ...keys, ...pluginModelToListKey }
460
+
461
+ const divergences: Divergence[] = []
462
+ for (const model of Object.keys(pluginModelToListKey)) {
463
+ const listKey = pluginModelToListKey[model]
464
+ compareModel(model, upstreamTables[model].fields, lists[listKey].fields, listKeys, divergences)
465
+ }
466
+
467
+ const unexpected = divergences.filter(
468
+ (d) => !KNOWN_EXCEPTIONS.some((known) => exceptionKey(known) === exceptionKey(d)),
469
+ )
470
+
471
+ it('has no unexpected divergences from better-auth’s MCP plugin OAuth tables', () => {
472
+ expect(unexpected, JSON.stringify(unexpected, null, 2)).toEqual([])
473
+ })
474
+
475
+ it('derives every OAuth table under a PascalCase key', () => {
476
+ for (const listKey of Object.values(pluginModelToListKey)) {
477
+ expect(lists).toHaveProperty(listKey)
478
+ }
479
+ })
480
+ })
@@ -252,6 +252,8 @@ describe('normalizeAuthConfig', () => {
252
252
  modelName: 'RateLimit',
253
253
  tableName: undefined,
254
254
  fields: {},
255
+ schema: undefined,
256
+ indexes: [],
255
257
  })
256
258
  })
257
259
 
@@ -279,6 +281,7 @@ describe('normalizeAuthConfig', () => {
279
281
  tableName: 'rate_limit',
280
282
  fields: { key: 'limit_key' },
281
283
  schema: 'auth',
284
+ indexes: [],
282
285
  })
283
286
  })
284
287