@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
@@ -1,29 +1,15 @@
1
1
  /**
2
- * Pure `better-auth config → Auth lists` derivation.
3
- *
4
- * This module is intentionally free of side effects and plugin/runtime
5
- * concerns: given the resolved better-auth model config (per-model `modelName`,
6
- * `tableName`, and `fields` column maps) plus any custom User fields, it
7
- * produces the four OpenSaaS Auth lists (user/session/account/verification)
8
- * with:
9
- *
10
- * - list keys taken from each model's `modelName`
11
- * - a table `@@map` (list-level `db.map`) taken from each model's resolved
12
- * `tableName` independent of `modelName`, so a renamed list key can still
13
- * adopt a differently-named live table
14
- * - field-level `@map` (`db.map`) for any better-auth field → column override
15
- * - relationship refs between the auth lists wired to the *derived* keys
16
- * (e.g. `Session.user → AuthUser.sessions`)
17
- *
18
- * When the developer supplies no `modelName`/`fields` overrides, the output
19
- * keeps the historical default keys (`User`/`Session`/`Account`/
20
- * `Verification`) and field shapes — see the unit tests. Per ADR-0013, each
21
- * list ships with **no** operation-level access unless the caller supplies it
22
- * (`accessConfig`, or `userConfig.access` for the user list) — deny-by-default,
23
- * not the plugin's former permissive defaults.
24
- *
25
- * `getAuthLists`/`convertBetterAuthSchema` (and the runtime user-key
26
- * resolution) consume this module so derivation lives in exactly one place.
2
+ * `better-auth config → Auth lists` derivation, free of side effects and
3
+ * plugin/runtime concerns. Reads better-auth's own resolved table
4
+ * definitions (`getAuthTables`, re-exported from `better-auth/db`) rather
5
+ * than hand-transcribing them, so the Auth lists cannot silently drift from
6
+ * what better-auth itself declares (issue #987). This single derivation also
7
+ * covers better-auth *plugin* tables (e.g. the MCP plugin's OAuth tables)
8
+ * `getAuthTables` merges a plugin's own `schema` into its result when
9
+ * `options.plugins` is populated, so one registry and one field-derivation
10
+ * pass cover base models and plugin tables alike (issue #992). See
11
+ * `packages/auth/CLAUDE.md` ("Deriving Auth lists from better-auth config")
12
+ * for the full behavior and examples.
27
13
  */
28
14
 
29
15
  import { list } from '@opensaas/stack-core'
@@ -35,18 +21,24 @@ import {
35
21
  bigInt,
36
22
  relationship,
37
23
  } from '@opensaas/stack-core/fields'
38
- import type { ListConfig } from '@opensaas/stack-core'
24
+ import { getAuthTables } from 'better-auth/db'
25
+ import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
26
+ import type { DBFieldAttribute } from 'better-auth/db'
27
+ import type { ListConfig, FieldConfig, ListIndex, FieldAccess } from '@opensaas/stack-core'
39
28
  import type { RelationshipField } from '@opensaas/stack-core/fields'
40
29
  import type { ExtendUserListConfig } from '../lists/index.js'
41
30
  import type { AuthAccessConfig, NormalizedAuthModelConfig, NormalizedAuthModels } from './types.js'
42
31
 
43
32
  /**
44
- * The derived Auth list set together with the keys each list was placed under.
45
- * Keys are surfaced separately so callers (plugin add-vs-extend logic, runtime
46
- * user-key resolution) don't have to re-derive them.
33
+ * The derived Auth list set together with the keys each of the five base
34
+ * better-auth models was placed under. Keys are surfaced separately so
35
+ * callers (plugin add-vs-extend logic, runtime user-key resolution) don't
36
+ * have to re-derive them. Better-auth *plugin* tables (e.g. `oauthApplication`)
37
+ * are derived alongside these but aren't named here — their keys are only
38
+ * needed internally, to resolve a reference target, and by callers that just
39
+ * want every derived list (`Object.keys(lists)`).
47
40
  */
48
41
  export type DerivedAuthLists = {
49
- /** Derived list keys, one per better-auth model. */
50
42
  keys: {
51
43
  user: string
52
44
  session: string
@@ -55,302 +47,743 @@ export type DerivedAuthLists = {
55
47
  /** Only present when a `RateLimit` list was derived (`rateLimit.storage === 'database'`). */
56
48
  rateLimit?: string
57
49
  }
58
- /** The derived list configs, keyed by their derived list keys. */
50
+ /** The derived list configs, keyed by their derived list keys — base models and plugin tables alike. */
59
51
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
60
52
  lists: Record<string, ListConfig<any>>
61
53
  }
62
54
 
55
+ /** better-auth's own fixed base model keys — independent of the stack's list-key overrides (`modelName`). Every other key `getAuthTables` returns is a plugin table. */
56
+ type BaseModelKey = 'user' | 'session' | 'account' | 'verification' | 'rateLimit'
57
+ const BASE_MODEL_KEYS: readonly BaseModelKey[] = [
58
+ 'user',
59
+ 'session',
60
+ 'account',
61
+ 'verification',
62
+ 'rateLimit',
63
+ ]
64
+
65
+ /**
66
+ * Declared field order for each of the five base models, independent of the
67
+ * order `getAuthTables` happens to return fields in. Prisma doesn't care
68
+ * about field order, but pinning it keeps the generated schema stable across
69
+ * this change (and future better-auth releases) instead of reshuffling on
70
+ * every regenerate — the reverse relations (`user.sessions`/`user.accounts`)
71
+ * are appended after these, in the order their owning model is processed
72
+ * below. A plugin table has no entry here — `assembleFields` falls back to
73
+ * upstream field order for any model this table doesn't list, which is every
74
+ * plugin table.
75
+ */
76
+ const FIELD_ORDER: Partial<Record<BaseModelKey, string[]>> = {
77
+ user: ['name', 'email', 'emailVerified', 'image'],
78
+ session: ['token', 'expiresAt', 'ipAddress', 'userAgent', 'user'],
79
+ account: [
80
+ 'accountId',
81
+ 'providerId',
82
+ // `issuer` (better-auth 1.7+, issue #986) groups with accountId/providerId
83
+ // as the account's identity fields — together the table-level
84
+ // `@@unique([issuer, accountId])` better-auth declares (not yet emitted;
85
+ // blocked on #986 reading better-auth's own table-level `indexes` through
86
+ // the app-supplied `db.indexes` passthrough #985 adds).
87
+ 'issuer',
88
+ 'user',
89
+ 'accessToken',
90
+ 'refreshToken',
91
+ 'accessTokenExpiresAt',
92
+ 'refreshTokenExpiresAt',
93
+ 'scope',
94
+ 'idToken',
95
+ 'password',
96
+ ],
97
+ verification: ['identifier', 'value', 'expiresAt'],
98
+ rateLimit: ['key', 'count', 'lastRequest'],
99
+ }
100
+
101
+ /** Carried via list-level `db.timestamps` (see `listDb`) rather than as ordinary derived fields. */
102
+ const TIMESTAMP_FIELDS = new Set(['createdAt', 'updatedAt'])
103
+
63
104
  /**
64
- * Build the list-level `db` config (`timestamps` + `@@map` + `@@schema`) for a
65
- * derived list.
105
+ * Fields that hold a live, presentable credential reading the value is
106
+ * equivalent to holding it (session hijack, account takeover, replaying an
107
+ * OAuth token) — rather than merely identifying a row. Keyed by better-auth's
108
+ * own model/field keys, not the app's list/column names, so the deny holds
109
+ * under `modelName` and column `fields` remapping alike (ADR-0036).
66
110
  *
67
- * `timestamps` is a per-model input rather than hardcoded: better-auth's
68
- * adapter writes `createdAt`/`updatedAt` on every user/session/account/
69
- * verification row (and the schema converter returns `null` for those columns,
70
- * assuming the generator injects them), so those four opt back into
71
- * auto-timestamps now that they're OFF by default (ADR-0004). The `RateLimit`
72
- * model has neither column in better-auth's own schema, so it passes `false`.
111
+ * Covers the four base models plus every better-auth plugin table the stack
112
+ * has first-class support for (ADR-0034's registry of known plugins) — the
113
+ * `mcp`/oauth-provider plugin's client secret and token columns, and
114
+ * `twoFactor()`'s encrypted secret/backup codes (issue #1014). An app can
115
+ * mark further fields via `authPlugin({ credentialFields })`, merged in by
116
+ * {@link buildCredentialFieldRegistry} this constant is never mutated.
117
+ */
118
+ const CREDENTIAL_FIELDS: Record<string, readonly string[]> = {
119
+ session: ['token'],
120
+ verification: ['value'],
121
+ account: ['password', 'accessToken', 'refreshToken', 'idToken'],
122
+ oauthClient: ['clientSecret'],
123
+ oauthAccessToken: ['token'],
124
+ oauthRefreshToken: ['token'],
125
+ twoFactor: ['secret', 'backupCodes'],
126
+ }
127
+
128
+ const DENY_READ: FieldAccess = { read: () => false }
129
+
130
+ /** Every better-auth model/field key marked as a credential — the stack's own {@link CREDENTIAL_FIELDS} plus an app's `credentialFields`. */
131
+ type CredentialFieldRegistry = Record<string, ReadonlySet<string>>
132
+
133
+ /**
134
+ * Merges the stack-seeded {@link CREDENTIAL_FIELDS} with an app's
135
+ * `authPlugin({ credentialFields })`, then validates every entry against the
136
+ * models actually being derived (`tables`, keyed by better-auth model key).
73
137
  *
74
- * The physical table name (`@@map`) comes from the model's resolved
75
- * `tableName`independent of the list key/`modelName` so a renamed list
76
- * key can still adopt a differently-named live table (e.g. better-auth's own
77
- * default lowercase table names). When a `schema` is configured (plugin-level
78
- * or per-model), the list is placed in that Postgres schema via `@@schema(...)`.
138
+ * Additive only: an app entry can add fields to a model — including a
139
+ * stack-seeded one but nothing can remove a seeded field, so a config that
140
+ * omits or empties a seeded model's list leaves that model's seeded deny
141
+ * standing.
79
142
  *
80
- * With no `tableName`/`schema` overrides and `timestamps: true` we emit only
81
- * `{ timestamps: true }`, leaving the default `User`/`Session`/... output
82
- * unchanged.
143
+ * A field named on a model that isn't in `tables` at all (a plugin the app
144
+ * doesn't use) is a silent no-op — the entry simply never matches anything.
145
+ * A field named on a model that IS in `tables` but doesn't declare that field
146
+ * throws, naming the model and field, since that is a config mistake the app
147
+ * would otherwise never learn about (the deny would just never fire).
83
148
  */
84
- function listDb(
85
- model: NormalizedAuthModelConfig,
86
- timestamps: boolean,
87
- ): { timestamps?: true; map?: string; schema?: string } {
88
- const schema = model.schema
149
+ function buildCredentialFieldRegistry(
150
+ tables: Record<string, ResolvedTable>,
151
+ appConfig: Record<string, string[]>,
152
+ ): CredentialFieldRegistry {
153
+ const merged = new Map<string, Set<string>>()
154
+ for (const [modelKey, fields] of Object.entries(CREDENTIAL_FIELDS)) {
155
+ merged.set(modelKey, new Set(fields))
156
+ }
157
+ for (const [modelKey, fields] of Object.entries(appConfig)) {
158
+ const set = merged.get(modelKey) ?? new Set<string>()
159
+ for (const fieldKey of fields) set.add(fieldKey)
160
+ merged.set(modelKey, set)
161
+ }
162
+
163
+ const registry: CredentialFieldRegistry = {}
164
+ for (const [modelKey, fields] of merged) {
165
+ const table = tables[modelKey]
166
+ if (!table) continue // model not derived (plugin unused) -> silent no-op
167
+ for (const fieldKey of fields) {
168
+ const upstream = table.fields[fieldKey]
169
+ if (!upstream) {
170
+ throw new Error(
171
+ `deriveAuthLists: credentialFields names "${modelKey}.${fieldKey}", but "${modelKey}" has no field "${fieldKey}"`,
172
+ )
173
+ }
174
+ // An id-referencing field derives to a relationship() (see the
175
+ // `references.field === 'id'` branch below), never a scalar field —
176
+ // withCredentialAccess is only ever applied on the scalar-field path,
177
+ // so a deny registered against one would silently never apply. Fail
178
+ // loudly instead of accepting a config that has no effect.
179
+ if (upstream.references?.field === 'id') {
180
+ throw new Error(
181
+ `deriveAuthLists: credentialFields names "${modelKey}.${fieldKey}", but "${fieldKey}" is a ` +
182
+ `relationship field (references "${upstream.references.model}.id"), not a scalar credential column`,
183
+ )
184
+ }
185
+ }
186
+ registry[modelKey] = fields
187
+ }
188
+ return registry
189
+ }
190
+
191
+ function withCredentialAccess(
192
+ registry: CredentialFieldRegistry,
193
+ modelKey: string,
194
+ fieldKey: string,
195
+ field: FieldConfig,
196
+ ): FieldConfig {
197
+ if (!registry[modelKey]?.has(fieldKey)) return field
89
198
  return {
90
- ...(timestamps ? { timestamps: true as const } : {}),
91
- ...(model.tableName !== undefined ? { map: model.tableName } : {}),
92
- ...(schema !== undefined ? { schema } : {}),
199
+ ...field,
200
+ access: DENY_READ,
201
+ // Curated out of the admin's default table columns too (issue #1018),
202
+ // via the same declared flag as everything else — a read-denied column
203
+ // would otherwise render permanently empty rather than simply absent.
204
+ ui: {
205
+ ...field.ui,
206
+ listView: { ...field.ui?.listView, defaultColumn: false },
207
+ },
93
208
  }
94
209
  }
95
210
 
96
211
  /**
97
- * Resolve the `db.map` (`@map` column override) for a better-auth field, or
98
- * `undefined` when there is no override (so default output is unchanged).
99
- *
100
- * The field builders capture `options.db` in a closure when generating Prisma
101
- * types, so the column map MUST be passed through the builder's `db` option
102
- * rather than patched onto the returned field object.
212
+ * Whether a model declares BOTH `createdAt` and `updatedAt` upstream the
213
+ * only shape `db.timestamps: true` can express, since it always emits both
214
+ * columns together (`resolveListTimestamps` in the Prisma generator). The
215
+ * four base models always satisfy this. A better-auth plugin table is not
216
+ * guaranteed to: the MCP plugin's OAuth tables (better-auth 1.7, issue #992)
217
+ * include several with only `createdAt` (e.g. `oauthAccessToken`,
218
+ * `oauthRefreshToken`, `oauthClientResource`) — those fall through to the
219
+ * general field-derivation loop below instead, so `createdAt` alone is still
220
+ * derived as an ordinary scalar column rather than silently dropped (which
221
+ * previously crashed a real write the moment better-auth's own adapter tried
222
+ * to set it — see `oauthResource`'s seeded row in the MCP OAuth cascade e2e
223
+ * test).
103
224
  */
104
- function fieldDb(fieldName: string, fields: Record<string, string>): { map: string } | undefined {
105
- const column = fields[fieldName]
106
- return column ? { map: column } : undefined
225
+ function hasSymmetricTimestamps(upstreamFields: Record<string, DBFieldAttribute>): boolean {
226
+ return 'createdAt' in upstreamFields && 'updatedAt' in upstreamFields
107
227
  }
108
228
 
109
229
  /**
110
- * Build the `db` config for a `user` relationship (`Session.user` /
111
- * `Account.user`), honouring a `userId` column override from the better-auth
112
- * `fields` map and mirroring better-auth's own FK shape: no separate FK index
113
- * the index is applied at the field level via `isIndexed: false` —
114
- * `onDelete: Cascade`, and a required (non-nullable) foreign key, since
115
- * better-auth's adapter always writes a `userId` on every session/account row
116
- * it creates. This means a generated Auth schema diffs clean against a live
117
- * better-auth database on all three dimensions instead of showing a spurious
118
- * index drop, a referential-action change, and a `DROP NOT NULL` (issues #679,
119
- * #863).
230
+ * The reverse relation field name a foreign key implies on its target model
231
+ * (e.g. `User.sessions`) has no source in better-auth's metadata at all —
232
+ * `references` declares only the child→parent link, never a name for the
233
+ * parent's reverse collection. Derived by pluralizing the child model's own
234
+ * key, which reproduces every current name; override here for a collision or
235
+ * a bad pluralization, one entry per better-auth model key (base or plugin).
120
236
  */
121
- function userRelationshipDb(fields: Record<string, string>): NonNullable<RelationshipField['db']> {
122
- const column = fields.userId
123
- return {
124
- isNullable: false,
125
- ...(column ? { foreignKey: { map: column } } : {}),
126
- extendPrismaSchema: ({ fkLine, relationLine }) => ({
127
- fkLine,
128
- relationLine: relationLine.replace('@relation(', '@relation(onDelete: Cascade, '),
129
- }),
237
+ const REVERSE_RELATION_NAME_OVERRIDES: Partial<Record<string, string>> = {}
238
+
239
+ function pluralize(word: string): string {
240
+ if (/[sxz]$|[cs]h$/.test(word)) return `${word}es`
241
+ if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`
242
+ return `${word}s`
243
+ }
244
+
245
+ function reverseRelationName(modelKey: string): string {
246
+ return REVERSE_RELATION_NAME_OVERRIDES[modelKey] ?? pluralize(modelKey)
247
+ }
248
+
249
+ /**
250
+ * The relationship field name a foreign key column derives to (e.g. `userId`
251
+ * → `user`) — better-auth models the column as a plain scalar carrying
252
+ * `references`; the stack models the same column as a `relationship()` under
253
+ * a different key. Stripping a trailing `Id` is the naming convention
254
+ * better-auth's own FK columns (and the stack's prior hand-written lists)
255
+ * already follow.
256
+ */
257
+ function relationshipFieldName(upstreamFieldKey: string): string {
258
+ return upstreamFieldKey.endsWith('Id') ? upstreamFieldKey.slice(0, -2) : upstreamFieldKey
259
+ }
260
+
261
+ const ON_DELETE_ACTIONS: Record<string, string> = {
262
+ cascade: 'Cascade',
263
+ restrict: 'Restrict',
264
+ 'set null': 'SetNull',
265
+ 'set default': 'SetDefault',
266
+ 'no action': 'NoAction',
267
+ }
268
+
269
+ function mapOnDelete(action: string): string {
270
+ return ON_DELETE_ACTIONS[action] ?? 'Cascade'
271
+ }
272
+
273
+ /**
274
+ * PascalCase a better-auth `modelName`, preserving internal word boundaries.
275
+ * Base models never go through this — their list key is always the
276
+ * configured `modelName` as-is (see `buildModelRegistry`) — only plugin
277
+ * tables do, since better-auth plugins declare camelCase (`oauthApplication`)
278
+ * or snake_case (`oauth_application`) model names and list keys must be
279
+ * PascalCase (issue #991).
280
+ */
281
+ function pascalCaseModelName(modelName: string): string {
282
+ if (/[_-]/.test(modelName)) {
283
+ return modelName
284
+ .split(/[_-]/)
285
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
286
+ .join('')
130
287
  }
288
+ return modelName.charAt(0).toUpperCase() + modelName.slice(1)
289
+ }
290
+
291
+ function scalarIsIndexed(upstream: DBFieldAttribute): true | 'unique' | undefined {
292
+ if (upstream.unique) return 'unique'
293
+ if (upstream.index) return true
294
+ return undefined
131
295
  }
132
296
 
133
297
  /**
134
- * Create the Auth user list, applying derived field column maps + table map and
135
- * wiring the session/account relationships to the derived keys.
136
- *
137
- * Per ADR-0013, the plugin ships no permissive access default: the list is
138
- * closed unless the application supplies access via `extendUserList.access`
139
- * (takes precedence it predates the keyed `access` passthrough and is
140
- * User-specific) or the `access.user` passthrough.
298
+ * The OpenSaaS field keys an app-supplied `db.indexes` entry (ADR-0035)
299
+ * claims on this model every field named in any entry's `fields` array,
300
+ * regardless of that entry's own arity or uniqueness. A claimed column's
301
+ * derived `isIndexed` (from better-auth's own `unique`/`index` flags) is
302
+ * suppressed so only the app's entry is emitted; suppression is per-column,
303
+ * so a composite entry claiming `identifier` leaves every other derived
304
+ * index on the model untouched.
141
305
  */
142
- function createUserList(
143
- model: NormalizedAuthModelConfig,
144
- keys: DerivedAuthLists['keys'],
145
- userConfig: ExtendUserListConfig,
146
- access: AuthAccessConfig['user'],
147
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
148
- ): ListConfig<any> {
149
- const f = model.fields
150
- return list({
151
- fields: {
152
- name: text({ validation: { isRequired: true }, db: fieldDb('name', f) }),
153
- email: text({
154
- validation: { isRequired: true },
155
- isIndexed: 'unique',
156
- db: fieldDb('email', f),
157
- }),
158
- emailVerified: checkbox({ defaultValue: false, db: fieldDb('emailVerified', f) }),
159
- image: text({ db: fieldDb('image', f) }),
306
+ function claimedIndexFields(indexes: ListIndex[]): Set<string> {
307
+ const claimed = new Set<string>()
308
+ for (const index of indexes) {
309
+ for (const fieldRef of index.fields) {
310
+ claimed.add(typeof fieldRef === 'string' ? fieldRef : fieldRef.field)
311
+ }
312
+ }
313
+ return claimed
314
+ }
160
315
 
161
- // Relationships to the other auth lists — refs follow the derived keys.
162
- sessions: relationship({ ref: `${keys.session}.user`, many: true }),
163
- accounts: relationship({ ref: `${keys.account}.user`, many: true }),
316
+ /**
317
+ * `db.isNullable` is set explicitly from `required` rather than left to each
318
+ * field builder's own default — `timestamp()` in particular defaults nullable
319
+ * off of whether it carries a `now()` default, not off requiredness, so
320
+ * leaving it implicit would silently produce a nullable column for a
321
+ * required upstream date field. `db.map` is set only when the resolved
322
+ * column name differs from the field's own key, so an unmodified field
323
+ * doesn't grow a redundant `@map`.
324
+ */
325
+ function scalarFieldDb(
326
+ fieldKey: string,
327
+ upstream: DBFieldAttribute,
328
+ ): { isNullable: boolean; map?: string } {
329
+ const columnName = upstream.fieldName ?? fieldKey
330
+ return {
331
+ isNullable: !(upstream.required ?? true),
332
+ ...(columnName !== fieldKey ? { map: columnName } : {}),
333
+ }
334
+ }
164
335
 
165
- // Custom fields from user config
166
- ...(userConfig.fields || {}),
167
- },
168
- db: listDb(model, true),
169
- access: userConfig.access || access,
170
- hooks: userConfig.hooks,
171
- })
336
+ function buildScalarField(
337
+ fieldKey: string,
338
+ upstream: DBFieldAttribute,
339
+ suppressIndex: boolean,
340
+ ): FieldConfig {
341
+ const isRequired = upstream.required ?? true
342
+ const isIndexed = suppressIndex ? undefined : scalarIsIndexed(upstream)
343
+ const db = scalarFieldDb(fieldKey, upstream)
344
+
345
+ switch (upstream.type) {
346
+ case 'string': {
347
+ const staticDefault =
348
+ typeof upstream.defaultValue === 'function' ? undefined : upstream.defaultValue
349
+ return text({
350
+ ...(isRequired ? { validation: { isRequired: true as const } } : {}),
351
+ ...(isIndexed ? { isIndexed } : {}),
352
+ ...(staticDefault !== undefined ? { defaultValue: staticDefault as string } : {}),
353
+ db,
354
+ })
355
+ }
356
+ case 'boolean': {
357
+ const staticDefault =
358
+ typeof upstream.defaultValue === 'function' ? undefined : upstream.defaultValue
359
+ return checkbox({
360
+ ...(isIndexed ? { isIndexed } : {}),
361
+ ...(staticDefault !== undefined ? { defaultValue: staticDefault as boolean } : {}),
362
+ db,
363
+ })
364
+ }
365
+ case 'date':
366
+ return timestamp({ ...(isIndexed ? { isIndexed } : {}), db })
367
+ case 'number': {
368
+ const staticDefault =
369
+ typeof upstream.defaultValue === 'function' ? undefined : upstream.defaultValue
370
+ return upstream.bigint
371
+ ? bigInt({
372
+ ...(isRequired ? { validation: { isRequired: true as const } } : {}),
373
+ ...(isIndexed ? { isIndexed } : {}),
374
+ ...(staticDefault !== undefined ? { defaultValue: staticDefault as number } : {}),
375
+ db,
376
+ })
377
+ : integer({
378
+ ...(isRequired ? { validation: { isRequired: true as const } } : {}),
379
+ ...(isIndexed ? { isIndexed } : {}),
380
+ ...(staticDefault !== undefined ? { defaultValue: staticDefault as number } : {}),
381
+ db,
382
+ })
383
+ }
384
+ default:
385
+ // better-auth's `DBFieldType` also allows `json`, `string[]`/`number[]`,
386
+ // and an enum array — none of which any built-in plugin (MCP, admin,
387
+ // organization, two-factor, ...) or the four base models actually use
388
+ // today. Warn and fall back to a plain `text()` column rather than
389
+ // throwing, so an app config that happens to hit this still generates
390
+ // (matching the pre-consolidation plugin-table converter's behavior)
391
+ // instead of crashing the whole `config()`/`generate` pipeline.
392
+ console.warn(
393
+ `[stack-auth] Unknown better-auth field type "${upstream.type}" for field "${fieldKey}", defaulting to text field`,
394
+ )
395
+ return text({
396
+ ...(isRequired ? { validation: { isRequired: true as const } } : {}),
397
+ ...(isIndexed ? { isIndexed } : {}),
398
+ db,
399
+ })
400
+ }
172
401
  }
173
402
 
174
403
  /**
175
- * Create the Auth session list.
404
+ * Build the `relationship()` field for a foreign-key column (e.g.
405
+ * `Session.user`), mirroring better-auth's own FK shape exactly: the
406
+ * physical column always maps explicitly to better-auth's resolved column
407
+ * name (even when it equals the field name — leaving it conditional would
408
+ * let the generator's Keystone-parity default take over and map to the
409
+ * relationship field name instead, reintroducing issue #935), requiredness
410
+ * and `onDelete` come from `references`, and the FK index/uniqueness mirror
411
+ * better-auth's own `index`/`unique` flags (ADR-0007).
176
412
  *
177
- * Per ADR-0013, the plugin ships no permissive access defaultclosed unless
178
- * the application supplies `access.session`.
413
+ * Only called for a reference whose target field is the target's `id` see
414
+ * the field-derivation loop in {@link deriveAuthLists} for the non-PK case
415
+ * (e.g. better-auth's own oidc-provider schema references
416
+ * `oauthApplication.clientId`, not its `id`), which `relationship()` cannot
417
+ * express and stays a plain scalar column instead.
179
418
  */
180
- function createSessionList(
181
- model: NormalizedAuthModelConfig,
182
- keys: DerivedAuthLists['keys'],
183
- access: AuthAccessConfig['session'],
184
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
185
- ): ListConfig<any> {
186
- const f = model.fields
187
- return list({
188
- fields: {
189
- token: text({
190
- validation: { isRequired: true },
191
- isIndexed: 'unique',
192
- db: fieldDb('token', f),
193
- }),
194
- expiresAt: timestamp({
195
- db: { isNullable: false, ...fieldDb('expiresAt', f) },
196
- }),
197
- ipAddress: text({ db: fieldDb('ipAddress', f) }),
198
- userAgent: text({ db: fieldDb('userAgent', f) }),
199
- user: relationship({
200
- ref: `${keys.user}.sessions`,
201
- isIndexed: false,
202
- db: userRelationshipDb(f),
419
+ function buildForeignKeyField(
420
+ fieldKey: string,
421
+ upstream: DBFieldAttribute,
422
+ targetListKey: string,
423
+ reverseFieldName: string,
424
+ suppressIndex: boolean,
425
+ ): RelationshipField {
426
+ const references = upstream.references
427
+ if (!references) {
428
+ throw new Error(
429
+ `deriveAuthLists: "${fieldKey}" was routed as a foreign key but has no references`,
430
+ )
431
+ }
432
+
433
+ const isRequired = upstream.required ?? true
434
+ const columnName = upstream.fieldName ?? fieldKey
435
+ const onDelete = mapOnDelete(references.onDelete ?? 'cascade')
436
+ // A relationship field's own generator defaults its FK index to indexed
437
+ // (true) whenever `isIndexed` is *omitted*, unlike a scalar field — so
438
+ // suppression can't just drop the property here the way `buildScalarField`
439
+ // does; it must set `isIndexed: false` explicitly to actually turn the
440
+ // derived index off.
441
+ const isIndexed = suppressIndex ? (false as const) : scalarIsIndexed(upstream)
442
+
443
+ return relationship({
444
+ ref: `${targetListKey}.${reverseFieldName}`,
445
+ ...(isIndexed !== undefined ? { isIndexed } : {}),
446
+ db: {
447
+ isNullable: !isRequired,
448
+ foreignKey: { map: columnName },
449
+ extendPrismaSchema: ({ fkLine, relationLine }) => ({
450
+ fkLine,
451
+ relationLine: relationLine.replace('@relation(', `@relation(onDelete: ${onDelete}, `),
203
452
  }),
204
453
  },
205
- db: listDb(model, true),
206
- access,
207
454
  })
208
455
  }
209
456
 
210
457
  /**
211
- * Create the Auth account list.
458
+ * Build the list-level `db` config (`timestamps` + `@@map` + `@@schema`) for
459
+ * a derived base-model list.
212
460
  *
213
- * Per ADR-0013, the plugin ships no permissive access default — closed unless
214
- * the application supplies `access.account`.
461
+ * `timestamps` is a per-model input, not hardcoded: better-auth's adapter
462
+ * writes `createdAt`/`updatedAt` on every user/session/account/verification
463
+ * row, so those four opt back into auto-timestamps now that they're OFF by
464
+ * default (ADR-0004). The `RateLimit` model has neither column upstream, so
465
+ * it passes `false`. Plugin tables don't go through this — see the "Out of
466
+ * scope" note on `deriveAuthLists` about not adding fields/behavior
467
+ * better-auth doesn't declare a JS-level default for.
215
468
  */
216
- function createAccountList(
469
+ function listDb(
217
470
  model: NormalizedAuthModelConfig,
218
- keys: DerivedAuthLists['keys'],
219
- access: AuthAccessConfig['account'],
220
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
221
- ): ListConfig<any> {
222
- const f = model.fields
223
- return list({
224
- fields: {
225
- accountId: text({ validation: { isRequired: true }, db: fieldDb('accountId', f) }),
226
- providerId: text({ validation: { isRequired: true }, db: fieldDb('providerId', f) }),
227
- user: relationship({
228
- ref: `${keys.user}.accounts`,
229
- isIndexed: false,
230
- db: userRelationshipDb(f),
231
- }),
232
- accessToken: text({ db: fieldDb('accessToken', f) }),
233
- refreshToken: text({ db: fieldDb('refreshToken', f) }),
234
- accessTokenExpiresAt: timestamp({ db: fieldDb('accessTokenExpiresAt', f) }),
235
- refreshTokenExpiresAt: timestamp({ db: fieldDb('refreshTokenExpiresAt', f) }),
236
- scope: text({ db: fieldDb('scope', f) }),
237
- idToken: text({ db: fieldDb('idToken', f) }),
238
- password: text({ db: fieldDb('password', f) }),
239
- },
240
- db: listDb(model, true),
241
- access,
242
- })
471
+ timestamps: boolean,
472
+ ): { timestamps?: true; map?: string; schema?: string; indexes?: ListIndex[] } {
473
+ const schema = model.schema
474
+ const indexes = model.indexes
475
+ return {
476
+ ...(timestamps ? { timestamps: true as const } : {}),
477
+ ...(model.tableName !== undefined ? { map: model.tableName } : {}),
478
+ ...(schema !== undefined ? { schema } : {}),
479
+ ...(indexes && indexes.length > 0 ? { indexes } : {}),
480
+ }
481
+ }
482
+
483
+ function betterAuthModelOptions(model: NormalizedAuthModelConfig): {
484
+ fields: Record<string, string>
485
+ } {
486
+ return { fields: model.fields }
243
487
  }
244
488
 
245
489
  /**
246
- * Create the Auth verification list.
247
- *
248
- * Per ADR-0013, the plugin ships no permissive access default — closed unless
249
- * the application supplies `access.verification`.
490
+ * The options object fed to `getAuthTables` — carries the per-model column
491
+ * overrides the app configured, plus the app's better-auth plugins so their
492
+ * own `schema` (base-model extensions and standalone plugin tables alike)
493
+ * merges into the same resolved table set (issue #992).
250
494
  */
251
- function createVerificationList(
252
- model: NormalizedAuthModelConfig,
253
- access: AuthAccessConfig['verification'],
254
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
255
- ): ListConfig<any> {
256
- const f = model.fields
257
- return list({
258
- fields: {
259
- identifier: text({ validation: { isRequired: true }, db: fieldDb('identifier', f) }),
260
- value: text({ validation: { isRequired: true }, db: fieldDb('value', f) }),
261
- expiresAt: timestamp({
262
- db: { isNullable: false, ...fieldDb('expiresAt', f) },
263
- }),
264
- },
265
- db: listDb(model, true),
266
- access,
267
- })
495
+ function buildBetterAuthTableOptions(
496
+ models: NormalizedAuthModels,
497
+ plugins: BetterAuthPlugin[],
498
+ ): BetterAuthOptions {
499
+ return {
500
+ user: betterAuthModelOptions(models.user),
501
+ session: betterAuthModelOptions(models.session),
502
+ account: betterAuthModelOptions(models.account),
503
+ verification: betterAuthModelOptions(models.verification),
504
+ ...(models.rateLimit
505
+ ? { rateLimit: { storage: 'database' as const, ...betterAuthModelOptions(models.rateLimit) } }
506
+ : {}),
507
+ ...(plugins.length ? { plugins } : {}),
508
+ }
268
509
  }
269
510
 
511
+ /** A resolved better-auth table definition, as `getAuthTables` returns it (base model or plugin table alike). */
512
+ type ResolvedTable = { modelName: string; fields: Record<string, DBFieldAttribute> }
513
+
270
514
  /**
271
- * Create the Auth rate-limit list, present only when `rateLimit.storage ===
272
- * 'database'` derives a `rateLimit` model. Mirrors better-auth's own
273
- * `rateLimit` table exactly (`getAuthTables` in `@better-auth/core`, verified
274
- * against better-auth@1.6.25): `key` (unique load-bearing, not cosmetic,
275
- * since the limiter races concurrent requests into a create and relies on the
276
- * resulting constraint violation to serialise them), `count`, and
277
- * `lastRequest` (a millisecond epoch, hence `bigInt()` rather than
278
- * `integer()`, which would overflow). None of the three columns carries a
279
- * Prisma default — the limiter supplies `lastRequest` explicitly on every
280
- * create/update, and an adopted live table has no default to match. No
281
- * `createdAt`/`updatedAt` either (see `listDb`'s `timestamps` argument):
282
- * better-auth's own rate-limit table has neither column.
515
+ * The model registry: every better-auth model key (base or plugin) mapped to
516
+ * its derived list key. Built in one pass over `getAuthTables`' full result,
517
+ * before any field is derived reference resolution needs a target's
518
+ * derived list key, and a reference can cross from a plugin table to a base
519
+ * model, from a plugin table to another plugin table, or (for the base
520
+ * models themselves) to another base model, so the whole set has to be known
521
+ * up front rather than resolved lazily per model.
283
522
  *
284
- * Per ADR-0013, the plugin ships no permissive access default closed unless
285
- * the application supplies `access.rateLimit`.
523
+ * Base models keep their existing behavior: the list key is exactly the
524
+ * configured `modelName` (already assumed PascalCase by convention, not
525
+ * re-cased here). A plugin table's list key is PascalCased from its resolved
526
+ * `modelName` (issue #991) — `getAuthTables` always resolves a plugin
527
+ * table's `modelName` (defaulting to its own schema key when the plugin
528
+ * doesn't set one), so this is never applied to an empty string.
286
529
  */
287
- function createRateLimitList(
288
- model: NormalizedAuthModelConfig,
289
- access: AuthAccessConfig['rateLimit'],
290
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
291
- ): ListConfig<any> {
292
- const f = model.fields
293
- return list({
294
- fields: {
295
- key: text({
296
- validation: { isRequired: true },
297
- isIndexed: 'unique',
298
- db: fieldDb('key', f),
299
- }),
300
- count: integer({
301
- validation: { isRequired: true },
302
- db: { isNullable: false, ...fieldDb('count', f) },
303
- }),
304
- lastRequest: bigInt({
305
- validation: { isRequired: true },
306
- db: { isNullable: false, ...fieldDb('lastRequest', f) },
307
- }),
308
- },
309
- db: listDb(model, false),
310
- access,
311
- })
530
+ function buildModelRegistry(
531
+ tables: Record<string, ResolvedTable>,
532
+ models: NormalizedAuthModels,
533
+ ): { keys: DerivedAuthLists['keys']; registry: Map<string, string> } {
534
+ const keys: DerivedAuthLists['keys'] = {
535
+ user: models.user.modelName,
536
+ session: models.session.modelName,
537
+ account: models.account.modelName,
538
+ verification: models.verification.modelName,
539
+ ...(models.rateLimit ? { rateLimit: models.rateLimit.modelName } : {}),
540
+ }
541
+
542
+ const registry = new Map<string, string>()
543
+ for (const baseKey of BASE_MODEL_KEYS) {
544
+ const listKey = keys[baseKey]
545
+ if (listKey) registry.set(baseKey, listKey)
546
+ }
547
+
548
+ for (const modelKey of Object.keys(tables)) {
549
+ if (registry.has(modelKey)) continue
550
+ registry.set(modelKey, pascalCaseModelName(tables[modelKey].modelName))
551
+ }
552
+
553
+ return { keys, registry }
312
554
  }
313
555
 
314
556
  /**
315
- * Derive the OpenSaaS Auth lists from the resolved better-auth model config.
557
+ * Derive the OpenSaaS Auth lists from the resolved better-auth model config
558
+ * the four base models, an optional database-backed `RateLimit`, and any
559
+ * table a better-auth plugin declares in its own `schema` (issue #992).
316
560
  *
317
- * Per ADR-0013 the derived lists ship **closed** (no permissive operation
561
+ * Per ADR-0013 every derived list ships **closed** (no permissive operation
318
562
  * access) unless the application supplies access via `accessConfig` (the
319
563
  * `authPlugin({ access: … })` passthrough, keyed by better-auth model name) or,
320
564
  * for the user list specifically, `userConfig.access` (`extendUserList.access`,
321
- * which takes precedence — see {@link AuthAccessConfig}).
565
+ * which takes precedence — see {@link AuthAccessConfig}). Plugin tables have no
566
+ * `access` passthrough at all — an app that needs to grant access declares the
567
+ * list itself under the same derived key so this function's field-only merge
568
+ * (in `authPlugin`'s add-vs-extend loop) leaves the app's own access standing.
322
569
  *
323
570
  * A fifth `RateLimit` list is included only when `models.rateLimit` is
324
571
  * present (i.e. `rateLimit.storage === 'database'`).
325
572
  *
326
- * @param models - Resolved better-auth per-model config (modelName + field column maps)
573
+ * Each base model's `indexes` (`AuthModelConfig.indexes`) carries through to
574
+ * the derived list's `db.indexes` unchanged, and suppresses this function's
575
+ * own derived field-level `isIndexed` for any column an entry names — the
576
+ * application's declaration wins over a derived default (ADR-0035).
577
+ *
578
+ * @param models - Resolved better-auth per-model config (modelName + field column maps + app-supplied indexes)
327
579
  * @param userConfig - Extra User-list fields/access/hooks supplied via `extendUserList`
328
- * @param accessConfig - App-authored access for each Auth list, keyed by better-auth model name
329
- * @returns The derived list keys and the Auth list configs keyed by those keys
580
+ * @param accessConfig - App-authored access for each base Auth list, keyed by better-auth model name
581
+ * @param plugins - The app's better-auth plugins (`authPlugin({ betterAuthPlugins })`), whose own
582
+ * `schema` (base-model extensions and standalone plugin tables) is derived alongside the base models
583
+ * @param credentialFieldsConfig - App-authored additions to the credential-field read-deny
584
+ * (`authPlugin({ credentialFields })`), keyed by better-auth model key. Additive only — see
585
+ * {@link buildCredentialFieldRegistry}.
586
+ * @returns The derived base-model list keys and every derived list config (base models and plugin tables)
330
587
  */
331
588
  export function deriveAuthLists(
332
589
  models: NormalizedAuthModels,
333
590
  userConfig: ExtendUserListConfig = {},
334
591
  accessConfig: AuthAccessConfig = {},
592
+ plugins: BetterAuthPlugin[] = [],
593
+ credentialFieldsConfig: Record<string, string[]> = {},
335
594
  ): DerivedAuthLists {
336
- const keys: DerivedAuthLists['keys'] = {
337
- user: models.user.modelName,
338
- session: models.session.modelName,
339
- account: models.account.modelName,
340
- verification: models.verification.modelName,
341
- ...(models.rateLimit ? { rateLimit: models.rateLimit.modelName } : {}),
595
+ const tables = getAuthTables(buildBetterAuthTableOptions(models, plugins)) as Record<
596
+ string,
597
+ ResolvedTable
598
+ >
599
+ const { keys, registry } = buildModelRegistry(tables, models)
600
+ const credentialRegistry = buildCredentialFieldRegistry(tables, credentialFieldsConfig)
601
+
602
+ // Only the five base models carry an app-authored `db.indexes` passthrough
603
+ // (`AuthModelConfig.indexes`) — plugin tables have no per-model config
604
+ // block to declare them through (deliberately out of scope, see the issue
605
+ // brief). Computed once per model rather than per field.
606
+ const claimedFieldsByModel: Partial<Record<BaseModelKey, Set<string>>> = {}
607
+ for (const baseKey of BASE_MODEL_KEYS) {
608
+ const model = models[baseKey]
609
+ if (model) claimedFieldsByModel[baseKey] = claimedIndexFields(model.indexes ?? [])
610
+ }
611
+
612
+ const scalarFields: Record<string, Record<string, FieldConfig>> = {}
613
+ const foreignKeyFields: Record<string, Record<string, RelationshipField>> = {}
614
+ const reverseRelationFields: Record<string, Record<string, RelationshipField>> = {}
615
+
616
+ for (const modelKey of Object.keys(tables)) {
617
+ const upstreamFields = tables[modelKey].fields
618
+ // Base models always carry `db.timestamps: true` below regardless of
619
+ // this check; a plugin table only skips createdAt/updatedAt here when it
620
+ // declares both (so db.timestamps: true, set further down, can stand in
621
+ // for them) — otherwise they fall through and derive as ordinary scalar
622
+ // columns instead of being silently dropped.
623
+ const skipTimestampFields =
624
+ (BASE_MODEL_KEYS as readonly string[]).includes(modelKey) ||
625
+ hasSymmetricTimestamps(upstreamFields)
626
+ for (const [fieldKey, upstream] of Object.entries(upstreamFields)) {
627
+ if (TIMESTAMP_FIELDS.has(fieldKey) && skipTimestampFields) continue
628
+
629
+ if (upstream.references) {
630
+ const targetModelKey = upstream.references.model
631
+ const targetListKey = registry.get(targetModelKey)
632
+ if (!targetListKey) {
633
+ throw new Error(
634
+ `deriveAuthLists: "${modelKey}.${fieldKey}" references unknown model "${targetModelKey}"`,
635
+ )
636
+ }
637
+
638
+ if (upstream.references.field === 'id') {
639
+ const relationFieldKey = relationshipFieldName(fieldKey)
640
+ const reverseName = reverseRelationName(modelKey)
641
+ if (reverseRelationFields[targetModelKey]?.[reverseName]) {
642
+ // The reverse name is derived from the *model* (pluralized), not
643
+ // the field — two id-referencing FKs on the same model targeting
644
+ // the same model would collapse onto one reverse field and
645
+ // silently produce an ambiguous/incorrect relation. Fail loudly;
646
+ // resolve via `REVERSE_RELATION_NAME_OVERRIDES`.
647
+ throw new Error(
648
+ `deriveAuthLists: "${modelKey}" has more than one reference to "${targetModelKey}" ` +
649
+ `resolving to the same reverse relation name "${reverseName}" — add an override to ` +
650
+ `REVERSE_RELATION_NAME_OVERRIDES in derive-auth-lists.ts`,
651
+ )
652
+ }
653
+ ;(foreignKeyFields[modelKey] ??= {})[relationFieldKey] = buildForeignKeyField(
654
+ fieldKey,
655
+ upstream,
656
+ targetListKey,
657
+ reverseName,
658
+ claimedFieldsByModel[modelKey as BaseModelKey]?.has(relationFieldKey) ?? false,
659
+ )
660
+ ;(reverseRelationFields[targetModelKey] ??= {})[reverseName] = relationship({
661
+ ref: `${registry.get(modelKey)}.${relationFieldKey}`,
662
+ many: true,
663
+ })
664
+ } else {
665
+ // relationship() always references the target's `id` column —
666
+ // better-auth's own oidc-provider schema (the MCP plugin's OAuth
667
+ // tables) references oauthApplication.clientId instead, which a
668
+ // relation can't express without pointing Prisma at the wrong
669
+ // column. Left as a plain scalar column, same as pre-consolidation
670
+ // behavior (issue #992).
671
+ ;(scalarFields[modelKey] ??= {})[fieldKey] = withCredentialAccess(
672
+ credentialRegistry,
673
+ modelKey,
674
+ fieldKey,
675
+ buildScalarField(
676
+ fieldKey,
677
+ upstream,
678
+ claimedFieldsByModel[modelKey as BaseModelKey]?.has(fieldKey) ?? false,
679
+ ),
680
+ )
681
+ }
682
+ } else {
683
+ ;(scalarFields[modelKey] ??= {})[fieldKey] = withCredentialAccess(
684
+ credentialRegistry,
685
+ modelKey,
686
+ fieldKey,
687
+ buildScalarField(
688
+ fieldKey,
689
+ upstream,
690
+ claimedFieldsByModel[modelKey as BaseModelKey]?.has(fieldKey) ?? false,
691
+ ),
692
+ )
693
+ }
694
+ }
695
+ }
696
+
697
+ /**
698
+ * `FIELD_ORDER` only pins the position of fields it names — it is never
699
+ * the source of truth for *which* fields exist. Every key derived from
700
+ * `tables[modelKey].fields` above (scalar or foreign-key) is emitted here
701
+ * regardless of whether `FIELD_ORDER` lists it, appended after the pinned
702
+ * ones in the order `getAuthTables` returned them, so a field a future
703
+ * better-auth release adds — the exact drift class this derivation exists
704
+ * to close (#986) — still reaches the generated schema; it just lands at
705
+ * the end of the model until `FIELD_ORDER` is updated to place it. A
706
+ * plugin table has no `FIELD_ORDER` entry at all, so every one of its
707
+ * fields lands in upstream order.
708
+ */
709
+ function assembleFields(modelKey: string): Record<string, FieldConfig> {
710
+ const fields: Record<string, FieldConfig> = {}
711
+ const remaining = new Map([
712
+ ...Object.entries(scalarFields[modelKey] ?? {}),
713
+ ...Object.entries(foreignKeyFields[modelKey] ?? {}),
714
+ ])
715
+
716
+ for (const fieldKey of FIELD_ORDER[modelKey as BaseModelKey] ?? []) {
717
+ const field = remaining.get(fieldKey)
718
+ if (field) {
719
+ fields[fieldKey] = field
720
+ remaining.delete(fieldKey)
721
+ }
722
+ }
723
+ for (const [fieldKey, field] of remaining) {
724
+ fields[fieldKey] = field
725
+ }
726
+
727
+ Object.assign(fields, reverseRelationFields[modelKey])
728
+ return fields
342
729
  }
343
730
 
344
731
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
345
732
  const lists: Record<string, ListConfig<any>> = {
346
- [keys.user]: createUserList(models.user, keys, userConfig, accessConfig.user),
347
- [keys.session]: createSessionList(models.session, keys, accessConfig.session),
348
- [keys.account]: createAccountList(models.account, keys, accessConfig.account),
349
- [keys.verification]: createVerificationList(models.verification, accessConfig.verification),
733
+ [keys.user]: list({
734
+ fields: { ...assembleFields('user'), ...(userConfig.fields || {}) },
735
+ db: listDb(models.user, true),
736
+ access: userConfig.access || accessConfig.user,
737
+ hooks: userConfig.hooks,
738
+ }),
739
+ [keys.session]: list({
740
+ fields: assembleFields('session'),
741
+ db: listDb(models.session, true),
742
+ access: accessConfig.session,
743
+ }),
744
+ [keys.account]: list({
745
+ fields: assembleFields('account'),
746
+ db: listDb(models.account, true),
747
+ access: accessConfig.account,
748
+ }),
749
+ [keys.verification]: list({
750
+ fields: assembleFields('verification'),
751
+ db: listDb(models.verification, true),
752
+ access: accessConfig.verification,
753
+ }),
350
754
  }
351
755
 
352
756
  if (models.rateLimit && keys.rateLimit) {
353
- lists[keys.rateLimit] = createRateLimitList(models.rateLimit, accessConfig.rateLimit)
757
+ lists[keys.rateLimit] = list({
758
+ fields: assembleFields('rateLimit'),
759
+ db: listDb(models.rateLimit, false),
760
+ access: accessConfig.rateLimit,
761
+ })
762
+ }
763
+
764
+ const baseKeysPresent = new Set<string>(['user', 'session', 'account', 'verification'])
765
+ if (models.rateLimit) baseKeysPresent.add('rateLimit')
766
+
767
+ for (const modelKey of Object.keys(tables)) {
768
+ if (baseKeysPresent.has(modelKey)) continue
769
+
770
+ const listKey = registry.get(modelKey)
771
+ if (!listKey) continue // unreachable: buildModelRegistry registers every table key
772
+
773
+ const physicalName = tables[modelKey].modelName
774
+ const timestamps = hasSymmetricTimestamps(tables[modelKey].fields)
775
+ const mapNeeded = listKey !== physicalName
776
+ lists[listKey] = list({
777
+ fields: assembleFields(modelKey),
778
+ ...(timestamps || mapNeeded
779
+ ? {
780
+ db: {
781
+ ...(timestamps ? { timestamps: true as const } : {}),
782
+ ...(mapNeeded ? { map: physicalName } : {}),
783
+ },
784
+ }
785
+ : {}),
786
+ })
354
787
  }
355
788
 
356
789
  return { keys, lists }