@opensaas/stack-auth 0.39.2 → 0.41.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +153 -0
- package/CLAUDE.md +140 -22
- package/dist/client/index.d.ts +0 -16
- package/dist/client/index.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.d.ts +4 -49
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +4 -51
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +42 -37
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +579 -223
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts +0 -3
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +4 -34
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +20 -90
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +89 -67
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -13
- package/dist/index.js.map +1 -1
- package/dist/lists/index.d.ts +9 -5
- package/dist/lists/index.d.ts.map +1 -1
- package/dist/lists/index.js +9 -6
- package/dist/lists/index.js.map +1 -1
- package/dist/mcp/better-auth.d.ts +6 -19
- package/dist/mcp/better-auth.d.ts.map +1 -1
- package/dist/mcp/better-auth.js +6 -20
- package/dist/mcp/better-auth.js.map +1 -1
- package/dist/plugins/index.d.ts +4 -4
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +4 -4
- package/dist/plugins/index.js.map +1 -1
- package/dist/runtime/types.d.ts +2 -9
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js +0 -4
- package/dist/runtime/types.js.map +1 -1
- package/dist/server/index.d.ts +5 -12
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +14 -62
- package/dist/server/index.js.map +1 -1
- package/dist/ui/components/ForgotPasswordForm.d.ts +0 -3
- package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -1
- package/dist/ui/components/ForgotPasswordForm.js +0 -3
- package/dist/ui/components/ForgotPasswordForm.js.map +1 -1
- package/dist/ui/components/ResetPasswordForm.d.ts +0 -3
- package/dist/ui/components/ResetPasswordForm.d.ts.map +1 -1
- package/dist/ui/components/ResetPasswordForm.js +0 -5
- package/dist/ui/components/ResetPasswordForm.js.map +1 -1
- package/dist/ui/components/SignInForm.d.ts +0 -3
- package/dist/ui/components/SignInForm.d.ts.map +1 -1
- package/dist/ui/components/SignInForm.js +0 -4
- package/dist/ui/components/SignInForm.js.map +1 -1
- package/dist/ui/components/SignUpForm.d.ts +0 -3
- package/dist/ui/components/SignUpForm.d.ts.map +1 -1
- package/dist/ui/components/SignUpForm.js +0 -5
- package/dist/ui/components/SignUpForm.js.map +1 -1
- package/dist/ui/lib/clean-error-message.d.ts +3 -6
- package/dist/ui/lib/clean-error-message.d.ts.map +1 -1
- package/dist/ui/lib/clean-error-message.js +3 -6
- package/dist/ui/lib/clean-error-message.js.map +1 -1
- package/dist/ui/types.d.ts +2 -15
- package/dist/ui/types.d.ts.map +1 -1
- package/dist/ui/types.js +2 -7
- package/dist/ui/types.js.map +1 -1
- package/package.json +12 -5
- package/src/client/index.ts +0 -17
- package/src/config/adopt-better-auth-tables.ts +4 -51
- package/src/config/derive-auth-lists.ts +685 -252
- package/src/config/index.ts +4 -34
- package/src/config/plugin.ts +21 -91
- package/src/config/types.ts +90 -67
- package/src/index.ts +3 -14
- package/src/lists/index.ts +17 -5
- package/src/mcp/better-auth.ts +6 -25
- package/src/plugins/index.ts +4 -5
- package/src/runtime/types.ts +2 -10
- package/src/server/index.ts +17 -70
- package/src/ui/components/ForgotPasswordForm.tsx +0 -3
- package/src/ui/components/ResetPasswordForm.tsx +0 -5
- package/src/ui/components/SignInForm.tsx +0 -4
- package/src/ui/components/SignUpForm.tsx +0 -5
- package/src/ui/lib/clean-error-message.ts +3 -6
- package/src/ui/types.ts +2 -15
- package/tests/auth-lists-drift.test.ts +480 -0
- package/tests/config.test.ts +3 -0
- package/tests/credential-field-read-deny-e2e.test.ts +295 -0
- package/tests/derive-auth-lists.test.ts +263 -6
- package/tests/generated-fk-shape.test.ts +432 -9
- package/tests/mcp-oauth-cascade-e2e.test.ts +267 -0
- package/tests/plugin-derived-keys.test.ts +39 -0
- package/tests/plugin-table-derivation.test.ts +270 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/server/schema-converter.d.ts +0 -81
- package/dist/server/schema-converter.d.ts.map +0 -1
- package/dist/server/schema-converter.js +0 -157
- package/dist/server/schema-converter.js.map +0 -1
- package/src/server/schema-converter.ts +0 -243
- package/tests/schema-converter.test.ts +0 -399
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
|
45
|
-
* Keys are surfaced separately so
|
|
46
|
-
* user-key resolution) don't
|
|
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
|
-
*
|
|
65
|
-
*
|
|
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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
):
|
|
88
|
-
const
|
|
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
|
-
...
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
|
105
|
-
|
|
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
|
-
*
|
|
111
|
-
* `
|
|
112
|
-
* `
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
178
|
-
* the
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
*
|
|
458
|
+
* Build the list-level `db` config (`timestamps` + `@@map` + `@@schema`) for
|
|
459
|
+
* a derived base-model list.
|
|
212
460
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
|
469
|
+
function listDb(
|
|
217
470
|
model: NormalizedAuthModelConfig,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* the
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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
|
-
*
|
|
285
|
-
*
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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]:
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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] =
|
|
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 }
|