@opensaas/stack-auth 0.39.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +153 -0
  3. package/CLAUDE.md +140 -22
  4. package/dist/client/index.d.ts +0 -16
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/config/adopt-better-auth-tables.d.ts +4 -49
  7. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  8. package/dist/config/adopt-better-auth-tables.js +4 -51
  9. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  10. package/dist/config/derive-auth-lists.d.ts +42 -37
  11. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  12. package/dist/config/derive-auth-lists.js +579 -223
  13. package/dist/config/derive-auth-lists.js.map +1 -1
  14. package/dist/config/index.d.ts +0 -3
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +4 -34
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/config/plugin.d.ts.map +1 -1
  19. package/dist/config/plugin.js +20 -90
  20. package/dist/config/plugin.js.map +1 -1
  21. package/dist/config/types.d.ts +89 -67
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/index.d.ts +1 -9
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -13
  26. package/dist/index.js.map +1 -1
  27. package/dist/lists/index.d.ts +9 -5
  28. package/dist/lists/index.d.ts.map +1 -1
  29. package/dist/lists/index.js +9 -6
  30. package/dist/lists/index.js.map +1 -1
  31. package/dist/mcp/better-auth.d.ts +6 -19
  32. package/dist/mcp/better-auth.d.ts.map +1 -1
  33. package/dist/mcp/better-auth.js +6 -20
  34. package/dist/mcp/better-auth.js.map +1 -1
  35. package/dist/plugins/index.d.ts +4 -4
  36. package/dist/plugins/index.d.ts.map +1 -1
  37. package/dist/plugins/index.js +4 -4
  38. package/dist/plugins/index.js.map +1 -1
  39. package/dist/runtime/types.d.ts +2 -9
  40. package/dist/runtime/types.d.ts.map +1 -1
  41. package/dist/runtime/types.js +0 -4
  42. package/dist/runtime/types.js.map +1 -1
  43. package/dist/server/index.d.ts +5 -12
  44. package/dist/server/index.d.ts.map +1 -1
  45. package/dist/server/index.js +14 -62
  46. package/dist/server/index.js.map +1 -1
  47. package/dist/ui/components/ForgotPasswordForm.d.ts +0 -3
  48. package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -1
  49. package/dist/ui/components/ForgotPasswordForm.js +0 -3
  50. package/dist/ui/components/ForgotPasswordForm.js.map +1 -1
  51. package/dist/ui/components/ResetPasswordForm.d.ts +0 -3
  52. package/dist/ui/components/ResetPasswordForm.d.ts.map +1 -1
  53. package/dist/ui/components/ResetPasswordForm.js +0 -5
  54. package/dist/ui/components/ResetPasswordForm.js.map +1 -1
  55. package/dist/ui/components/SignInForm.d.ts +0 -3
  56. package/dist/ui/components/SignInForm.d.ts.map +1 -1
  57. package/dist/ui/components/SignInForm.js +0 -4
  58. package/dist/ui/components/SignInForm.js.map +1 -1
  59. package/dist/ui/components/SignUpForm.d.ts +0 -3
  60. package/dist/ui/components/SignUpForm.d.ts.map +1 -1
  61. package/dist/ui/components/SignUpForm.js +0 -5
  62. package/dist/ui/components/SignUpForm.js.map +1 -1
  63. package/dist/ui/lib/clean-error-message.d.ts +3 -6
  64. package/dist/ui/lib/clean-error-message.d.ts.map +1 -1
  65. package/dist/ui/lib/clean-error-message.js +3 -6
  66. package/dist/ui/lib/clean-error-message.js.map +1 -1
  67. package/dist/ui/types.d.ts +2 -15
  68. package/dist/ui/types.d.ts.map +1 -1
  69. package/dist/ui/types.js +2 -7
  70. package/dist/ui/types.js.map +1 -1
  71. package/package.json +12 -5
  72. package/src/client/index.ts +0 -17
  73. package/src/config/adopt-better-auth-tables.ts +4 -51
  74. package/src/config/derive-auth-lists.ts +685 -252
  75. package/src/config/index.ts +4 -34
  76. package/src/config/plugin.ts +21 -91
  77. package/src/config/types.ts +90 -67
  78. package/src/index.ts +3 -14
  79. package/src/lists/index.ts +17 -5
  80. package/src/mcp/better-auth.ts +6 -25
  81. package/src/plugins/index.ts +4 -5
  82. package/src/runtime/types.ts +2 -10
  83. package/src/server/index.ts +17 -70
  84. package/src/ui/components/ForgotPasswordForm.tsx +0 -3
  85. package/src/ui/components/ResetPasswordForm.tsx +0 -5
  86. package/src/ui/components/SignInForm.tsx +0 -4
  87. package/src/ui/components/SignUpForm.tsx +0 -5
  88. package/src/ui/lib/clean-error-message.ts +3 -6
  89. package/src/ui/types.ts +2 -15
  90. package/tests/auth-lists-drift.test.ts +480 -0
  91. package/tests/config.test.ts +3 -0
  92. package/tests/credential-field-read-deny-e2e.test.ts +295 -0
  93. package/tests/derive-auth-lists.test.ts +263 -6
  94. package/tests/generated-fk-shape.test.ts +432 -9
  95. package/tests/mcp-oauth-cascade-e2e.test.ts +267 -0
  96. package/tests/plugin-derived-keys.test.ts +39 -0
  97. package/tests/plugin-table-derivation.test.ts +270 -0
  98. package/tsconfig.tsbuildinfo +1 -1
  99. package/dist/server/schema-converter.d.ts +0 -81
  100. package/dist/server/schema-converter.d.ts.map +0 -1
  101. package/dist/server/schema-converter.js +0 -157
  102. package/dist/server/schema-converter.js.map +0 -1
  103. package/src/server/schema-converter.ts +0 -243
  104. package/tests/schema-converter.test.ts +0 -399
@@ -1,280 +1,636 @@
1
1
  /**
2
- * Pure `better-auth config → Auth lists` derivation.
3
- *
4
- * This module is intentionally free of side effects and plugin/runtime
5
- * concerns: given the resolved better-auth model config (per-model `modelName`,
6
- * `tableName`, and `fields` column maps) plus any custom User fields, it
7
- * produces the four OpenSaaS Auth lists (user/session/account/verification)
8
- * with:
9
- *
10
- * - list keys taken from each model's `modelName`
11
- * - a table `@@map` (list-level `db.map`) taken from each model's resolved
12
- * `tableName` independent of `modelName`, so a renamed list key can still
13
- * adopt a differently-named live table
14
- * - field-level `@map` (`db.map`) for any better-auth field → column override
15
- * - relationship refs between the auth lists wired to the *derived* keys
16
- * (e.g. `Session.user → AuthUser.sessions`)
17
- *
18
- * When the developer supplies no `modelName`/`fields` overrides, the output
19
- * keeps the historical default keys (`User`/`Session`/`Account`/
20
- * `Verification`) and field shapes — see the unit tests. Per ADR-0013, each
21
- * list ships with **no** operation-level access unless the caller supplies it
22
- * (`accessConfig`, or `userConfig.access` for the user list) — deny-by-default,
23
- * not the plugin's former permissive defaults.
24
- *
25
- * `getAuthLists`/`convertBetterAuthSchema` (and the runtime user-key
26
- * resolution) consume this module so derivation lives in exactly one place.
2
+ * `better-auth config → Auth lists` derivation, free of side effects and
3
+ * plugin/runtime concerns. Reads better-auth's own resolved table
4
+ * definitions (`getAuthTables`, re-exported from `better-auth/db`) rather
5
+ * than hand-transcribing them, so the Auth lists cannot silently drift from
6
+ * what better-auth itself declares (issue #987). This single derivation also
7
+ * covers better-auth *plugin* tables (e.g. the MCP plugin's OAuth tables)
8
+ * `getAuthTables` merges a plugin's own `schema` into its result when
9
+ * `options.plugins` is populated, so one registry and one field-derivation
10
+ * pass cover base models and plugin tables alike (issue #992). See
11
+ * `packages/auth/CLAUDE.md` ("Deriving Auth lists from better-auth config")
12
+ * for the full behavior and examples.
27
13
  */
28
14
  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
- * Build the list-level `db` config (`timestamps` + `@@map` + `@@schema`) for a
32
- * derived list.
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
- * `timestamps` is a per-model input rather than hardcoded: better-auth's
35
- * adapter writes `createdAt`/`updatedAt` on every user/session/account/
36
- * verification row (and the schema converter returns `null` for those columns,
37
- * assuming the generator injects them), so those four opt back into
38
- * auto-timestamps now that they're OFF by default (ADR-0004). The `RateLimit`
39
- * model has neither column in better-auth's own schema, so it passes `false`.
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
- * The physical table name (`@@map`) comes from the model's resolved
42
- * `tableName`independent of the list key/`modelName` so a renamed list
43
- * key can still adopt a differently-named live table (e.g. better-auth's own
44
- * default lowercase table names). When a `schema` is configured (plugin-level
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
- * With no `tableName`/`schema` overrides and `timestamps: true` we emit only
48
- * `{ timestamps: true }`, leaving the default `User`/`Session`/... output
49
- * unchanged.
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 listDb(model, timestamps) {
52
- const schema = model.schema;
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
- ...(timestamps ? { timestamps: true } : {}),
55
- ...(model.tableName !== undefined ? { map: model.tableName } : {}),
56
- ...(schema !== undefined ? { schema } : {}),
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
- * Resolve the `db.map` (`@map` column override) for a better-auth field, or
61
- * `undefined` when there is no override (so default output is unchanged).
62
- *
63
- * The field builders capture `options.db` in a closure when generating Prisma
64
- * types, so the column map MUST be passed through the builder's `db` option
65
- * rather than patched onto the returned field object.
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 fieldDb(fieldName, fields) {
68
- const column = fields[fieldName];
69
- return column ? { map: column } : undefined;
165
+ function hasSymmetricTimestamps(upstreamFields) {
166
+ return 'createdAt' in upstreamFields && 'updatedAt' in upstreamFields;
70
167
  }
71
168
  /**
72
- * Build the `db` config for a `user` relationship (`Session.user` /
73
- * `Account.user`), honouring a `userId` column override from the better-auth
74
- * `fields` map and mirroring better-auth's own FK shape: no separate FK index
75
- * the index is applied at the field level via `isIndexed: false` —
76
- * `onDelete: Cascade`, and a required (non-nullable) foreign key, since
77
- * better-auth's adapter always writes a `userId` on every session/account row
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
- function userRelationshipDb(fields) {
84
- const column = fields.userId;
85
- return {
86
- isNullable: false,
87
- ...(column ? { foreignKey: { map: column } } : {}),
88
- extendPrismaSchema: ({ fkLine, relationLine }) => ({
89
- fkLine,
90
- relationLine: relationLine.replace('@relation(', '@relation(onDelete: Cascade, '),
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
- * Create the Auth user list, applying derived field column maps + table map and
96
- * wiring the session/account relationships to the derived keys.
97
- *
98
- * Per ADR-0013, the plugin ships no permissive access default: the list is
99
- * closed unless the application supplies access via `extendUserList.access`
100
- * (takes precedence — it predates the keyed `access` passthrough and is
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 createUserList(model, keys, userConfig, access) {
104
- const f = model.fields;
105
- return list({
106
- fields: {
107
- name: text({ validation: { isRequired: true }, db: fieldDb('name', f) }),
108
- email: text({
109
- validation: { isRequired: true },
110
- isIndexed: 'unique',
111
- db: fieldDb('email', f),
112
- }),
113
- emailVerified: checkbox({ defaultValue: false, db: fieldDb('emailVerified', f) }),
114
- image: text({ db: fieldDb('image', f) }),
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
- * Create the Auth session list.
128
- *
129
- * Per ADR-0013, the plugin ships no permissive access default closed unless
130
- * the application supplies `access.session`.
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 createSessionList(model, keys, access) {
133
- const f = model.fields;
134
- return list({
135
- fields: {
136
- token: text({
137
- validation: { isRequired: true },
138
- isIndexed: 'unique',
139
- db: fieldDb('token', f),
140
- }),
141
- expiresAt: timestamp({
142
- db: { isNullable: false, ...fieldDb('expiresAt', f) },
143
- }),
144
- ipAddress: text({ db: fieldDb('ipAddress', f) }),
145
- userAgent: text({ db: fieldDb('userAgent', f) }),
146
- user: relationship({
147
- ref: `${keys.user}.sessions`,
148
- isIndexed: false,
149
- db: userRelationshipDb(f),
150
- }),
151
- },
152
- db: listDb(model, true),
153
- access,
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
- * Create the Auth account list.
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
- * Per ADR-0013, the plugin ships no permissive access defaultclosed unless
160
- * the application supplies `access.account`.
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 createAccountList(model, keys, access) {
163
- const f = model.fields;
164
- return list({
165
- fields: {
166
- accountId: text({ validation: { isRequired: true }, db: fieldDb('accountId', f) }),
167
- providerId: text({ validation: { isRequired: true }, db: fieldDb('providerId', f) }),
168
- user: relationship({
169
- ref: `${keys.user}.accounts`,
170
- isIndexed: false,
171
- db: userRelationshipDb(f),
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
- * Create the Auth verification list.
366
+ * Build the list-level `db` config (`timestamps` + `@@map` + `@@schema`) for
367
+ * a derived base-model list.
187
368
  *
188
- * Per ADR-0013, the plugin ships no permissive access default — closed unless
189
- * the application supplies `access.verification`.
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 createVerificationList(model, access) {
192
- const f = model.fields;
193
- return list({
194
- fields: {
195
- identifier: text({ validation: { isRequired: true }, db: fieldDb('identifier', f) }),
196
- value: text({ validation: { isRequired: true }, db: fieldDb('value', f) }),
197
- expiresAt: timestamp({
198
- db: { isNullable: false, ...fieldDb('expiresAt', f) },
199
- }),
200
- },
201
- db: listDb(model, true),
202
- access,
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
- * Create the Auth rate-limit list, present only when `rateLimit.storage ===
207
- * 'database'` derives a `rateLimit` model. Mirrors better-auth's own
208
- * `rateLimit` table exactly (`getAuthTables` in `@better-auth/core`, verified
209
- * against better-auth@1.6.25): `key` (unique load-bearing, not cosmetic,
210
- * since the limiter races concurrent requests into a create and relies on the
211
- * resulting constraint violation to serialise them), `count`, and
212
- * `lastRequest` (a millisecond epoch, hence `bigInt()` rather than
213
- * `integer()`, which would overflow). None of the three columns carries a
214
- * Prisma default — the limiter supplies `lastRequest` explicitly on every
215
- * create/update, and an adopted live table has no default to match. No
216
- * `createdAt`/`updatedAt` either (see `listDb`'s `timestamps` argument):
217
- * better-auth's own rate-limit table has neither column.
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
- * Per ADR-0013, the plugin ships no permissive access default closed unless
220
- * the application supplies `access.rateLimit`.
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 createRateLimitList(model, access) {
223
- const f = model.fields;
224
- return list({
225
- fields: {
226
- key: text({
227
- validation: { isRequired: true },
228
- isIndexed: 'unique',
229
- db: fieldDb('key', f),
230
- }),
231
- count: integer({
232
- validation: { isRequired: true },
233
- db: { isNullable: false, ...fieldDb('count', f) },
234
- }),
235
- lastRequest: bigInt({
236
- validation: { isRequired: true },
237
- db: { isNullable: false, ...fieldDb('lastRequest', f) },
238
- }),
239
- },
240
- db: listDb(model, false),
241
- access,
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 the derived lists ship **closed** (no permissive operation
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
- * @param models - Resolved better-auth per-model config (modelName + field column maps)
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
- * @returns The derived list keys and the Auth list configs keyed by those keys
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 keys = {
263
- user: models.user.modelName,
264
- session: models.session.modelName,
265
- account: models.account.modelName,
266
- verification: models.verification.modelName,
267
- ...(models.rateLimit ? { rateLimit: models.rateLimit.modelName } : {}),
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]: createUserList(models.user, keys, userConfig, accessConfig.user),
272
- [keys.session]: createSessionList(models.session, keys, accessConfig.session),
273
- [keys.account]: createAccountList(models.account, keys, accessConfig.account),
274
- [keys.verification]: createVerificationList(models.verification, accessConfig.verification),
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] = createRateLimitList(models.rateLimit, accessConfig.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
  }