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