@opensaas/stack-auth 0.39.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -10,12 +10,6 @@ import type {
10
10
  SendAuthEmail,
11
11
  } from './types.js'
12
12
 
13
- /**
14
- * Default better-auth model names. Used when the developer does not override
15
- * `modelName`, preserving the historical `User`/`Session`/`Account`/`Verification`
16
- * keys exactly. `rateLimit` only applies when `rateLimit.storage: 'database'`
17
- * derives the fifth Auth list.
18
- */
19
13
  const DEFAULT_MODEL_NAMES = {
20
14
  user: 'User',
21
15
  session: 'Session',
@@ -25,11 +19,6 @@ const DEFAULT_MODEL_NAMES = {
25
19
  } as const
26
20
 
27
21
  /**
28
- * Resolve a single better-auth model config block into its normalized form,
29
- * falling back to the better-auth default model name and an empty column map.
30
- * The model's Postgres schema is the per-model `schema` override when present,
31
- * otherwise the plugin-level `schema` default (or `undefined` for `public`).
32
- *
33
22
  * `tableName` defaults to today's behaviour when not explicitly set: it
34
23
  * follows `modelName` when that differs from the better-auth default (so a
35
24
  * renamed list still pins its table via `@@map`), otherwise it stays unset.
@@ -49,15 +38,10 @@ function normalizeModelConfig(
49
38
  tableName,
50
39
  fields: config?.fields || {},
51
40
  schema: config?.schema ?? defaultSchema,
41
+ indexes: config?.indexes ?? [],
52
42
  }
53
43
  }
54
44
 
55
- /**
56
- * Resolve the better-auth model config for all four auth models, plus a fifth
57
- * `rateLimit` model when `rateLimit.storage: 'database'` requires deriving
58
- * the `RateLimit` list. `defaultSchema` is the plugin-level `schema` applied
59
- * to every model unless a per-model `schema` override is given.
60
- */
61
45
  function normalizeAuthModels(config: AuthConfig): NormalizedAuthModels {
62
46
  const defaultSchema = config.schema
63
47
  const models: NormalizedAuthModels = {
@@ -82,11 +66,6 @@ function normalizeAuthModels(config: AuthConfig): NormalizedAuthModels {
82
66
  return models
83
67
  }
84
68
 
85
- /**
86
- * Default `sendResetPassword`/`sendVerificationEmail` — logs to console
87
- * instead of sending, matching the pre-existing "no sendEmail configured"
88
- * behavior for apps that haven't wired a real email provider yet.
89
- */
90
69
  function defaultSendAuthEmail(kind: 'password reset' | 'verification'): SendAuthEmail {
91
70
  return async ({ user, url }) => {
92
71
  console.log(
@@ -97,11 +76,7 @@ function defaultSendAuthEmail(kind: 'password reset' | 'verification'): SendAuth
97
76
  }
98
77
  }
99
78
 
100
- /**
101
- * Normalize auth configuration with defaults
102
- */
103
79
  export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
104
- // Email and password defaults
105
80
  const emailAndPassword = config.emailAndPassword?.enabled
106
81
  ? {
107
82
  enabled: true as const,
@@ -119,7 +94,6 @@ export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
119
94
  sendResetPassword: defaultSendAuthEmail('password reset'),
120
95
  }
121
96
 
122
- // Email verification defaults
123
97
  const emailVerification = config.emailVerification?.enabled
124
98
  ? {
125
99
  enabled: true as const,
@@ -137,7 +111,6 @@ export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
137
111
  sendVerificationEmail: defaultSendAuthEmail('verification'),
138
112
  }
139
113
 
140
- // Password reset defaults
141
114
  const passwordReset = config.passwordReset?.enabled
142
115
  ? {
143
116
  enabled: true as const,
@@ -145,17 +118,13 @@ export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
145
118
  }
146
119
  : { enabled: false as const, tokenExpiration: 3600 }
147
120
 
148
- // Session defaults
149
121
  const session = {
150
- expiresIn: config.session?.expiresIn || 604800, // 7 days
151
- updateAge: config.session?.updateAge ?? 86400, // 1 day, matching better-auth's own default
122
+ expiresIn: config.session?.expiresIn || 604800,
123
+ updateAge: config.session?.updateAge ?? 86400, // matches better-auth's own default
152
124
  }
153
125
 
154
- // Session fields defaults
155
126
  const sessionFields = config.sessionFields || ['userId', 'email', 'name']
156
127
 
157
- // Resolve better-auth per-model config (modelName + field column maps).
158
- // Defaults preserve the historical User/Session/Account/Verification keys.
159
128
  const models = normalizeAuthModels(config)
160
129
 
161
130
  return {
@@ -169,6 +138,7 @@ export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
169
138
  sessionFields,
170
139
  extendUserList: config.extendUserList || {},
171
140
  access: config.access || {},
141
+ credentialFields: config.credentialFields || {},
172
142
  betterAuthPlugins: config.betterAuthPlugins || [],
173
143
  rateLimit: config.rateLimit,
174
144
  betterAuthOptions: config.betterAuthOptions || {},
@@ -3,7 +3,6 @@ import { getDbKey } from '@opensaas/stack-core'
3
3
  import type { AuthConfig, NormalizedAuthConfig } from './types.js'
4
4
  import { normalizeAuthConfig } from './index.js'
5
5
  import { getAuthLists } from '../lists/index.js'
6
- import { convertBetterAuthSchema } from '../server/schema-converter.js'
7
6
 
8
7
  /**
9
8
  * Auth plugin for OpenSaas Stack
@@ -39,49 +38,29 @@ export function authPlugin(config: AuthConfig): Plugin {
39
38
  },
40
39
 
41
40
  init: async (context) => {
42
- // Derive the auth lists from the better-auth model config (modelName +
43
- // field column maps). With no overrides this yields the historical
44
- // User/Session/Account/Verification keys; with overrides (e.g.
45
- // user.modelName: 'AuthUser') the lists are keyed and column-mapped to
46
- // match the developer's live better-auth tables.
41
+ // One consolidated derivation covers the four base models, the
42
+ // optional RateLimit model, and every table a better-auth plugin
43
+ // declares in its own `schema` (base-model extensions like the
44
+ // `anonymous` plugin's `user.isAnonymous`, and standalone tables like
45
+ // the MCP plugin's OAuth tables) — see `deriveAuthLists` (issue #992).
46
+ // `getAuthTables` merges a plugin's base-model schema extension
47
+ // directly into that model's own fields, so the derived `User`/`AuthUser`
48
+ // list here already carries e.g. `isAnonymous` before this loop runs.
47
49
  const authLists = getAuthLists(
48
50
  normalized.extendUserList,
49
51
  normalized.models,
50
52
  normalized.access,
53
+ normalized.betterAuthPlugins,
54
+ normalized.credentialFields,
51
55
  )
52
56
 
53
- // The same base-model list keys the Auth lists above were derived under
54
- // (e.g. `user.modelName: 'AuthUser'`). A provider plugin's schema
55
- // extension of a base model (e.g. `user`) must resolve against this
56
- // remap too, so it lands on the adopted Auth list rather than a
57
- // re-derived key that can collide with an unrelated host list. The
58
- // `rateLimit` key is only present when `rateLimit.storage: 'database'`
59
- // derived the fifth Auth list.
60
- const baseModelKeys = {
61
- user: normalized.models.user.modelName,
62
- session: normalized.models.session.modelName,
63
- account: normalized.models.account.modelName,
64
- verification: normalized.models.verification.modelName,
65
- ...(normalized.models.rateLimit
66
- ? { rateLimit: normalized.models.rateLimit.modelName }
67
- : {}),
68
- }
69
-
70
- // Add all auth lists FIRST, before any better-auth plugin schema
71
- // extension is processed. This must happen before the betterAuthPlugins
72
- // loop below so a base-model extension (e.g. a plugin's `schema: {
73
- // user: { fields: … } }`) always finds the real derived list already
74
- // registered under its key and takes the merge (`extendList`) path
75
- // against it — instead of pre-empting that key with a bare
76
- // `list({ fields })` that carries no `db`/`access` (see #861).
77
- //
78
- // The plugin only ever touches its OWN derived keys. When a developer
79
- // renames the auth user model (e.g. user.modelName: 'AuthUser'), the
80
- // derived key is 'AuthUser' and an app's separate 'User' list is left
81
- // untouched — the plugin never extends/overwrites a list it didn't
82
- // derive. Extending only kicks in when an existing list shares the
83
- // derived key (e.g. the default 'User'), which is the intended
84
- // "merge auth fields into my User" behaviour.
57
+ // Base models are always the first entries in `authLists` (see
58
+ // `deriveAuthLists`), so a plugin table's reverse relation onto a base
59
+ // model (e.g. `AuthUser.oauthApplications`) is already part of that
60
+ // base list's `fields` by the time this loop reaches it one pass
61
+ // suffices for both: a list already declared by the app (or added by
62
+ // an earlier iteration of this same loop) merges via `extendList`;
63
+ // everything else registers via `addList`.
85
64
  for (const [listName, listConfig] of Object.entries(authLists)) {
86
65
  if (context.config.lists[listName]) {
87
66
  // A list already exists under this derived key — merge auth fields
@@ -94,55 +73,14 @@ export function authPlugin(config: AuthConfig): Plugin {
94
73
  mcp: listConfig.mcp,
95
74
  })
96
75
  } else {
97
- // Otherwise, add the auth list
98
76
  context.addList(listName, listConfig)
99
77
  }
100
78
  }
101
79
 
102
- // Extract additional lists from Better Auth plugins. Because the auth
103
- // lists above are already registered, a schema extension of a base
104
- // model (user/session/account/verification) always finds its resolved
105
- // key occupied by the real derived list and merges via `extendList` —
106
- // its `db`/`access` are preserved. Non-base plugin tables (e.g.
107
- // `oauth_application`, `passkey`) still register as new lists via
108
- // `addList`, same as before.
109
- for (const plugin of normalized.betterAuthPlugins) {
110
- if (plugin && typeof plugin === 'object' && plugin.schema) {
111
- // Plugin has schema property - convert to OpenSaaS lists
112
- const pluginSchema = plugin.schema
113
- const pluginLists = convertBetterAuthSchema(pluginSchema, baseModelKeys)
114
-
115
- // Add or extend lists from plugin
116
- for (const [listName, listConfig] of Object.entries(pluginLists)) {
117
- if (context.config.lists[listName]) {
118
- // List already exists — merge fields/hooks/mcp in only. Access
119
- // control belongs to whoever owns the list; per ADR-0013 an
120
- // extension must never carry operation-level access for a
121
- // pre-existing list (the plugin engine throws if it does).
122
- context.extendList(listName, {
123
- fields: listConfig.fields,
124
- hooks: listConfig.hooks,
125
- mcp: listConfig.mcp,
126
- })
127
- } else {
128
- // List doesn't exist, add it
129
- context.addList(listName, listConfig)
130
- }
131
- }
132
- }
133
- }
134
-
135
- // Store auth config for runtime access
136
- // Access at runtime via: config._pluginData.auth
137
80
  context.setPluginData<NormalizedAuthConfig>('auth', normalized)
138
81
  },
139
82
 
140
83
  beforeGenerate: (generationConfig) => {
141
- // Collect every schema the Auth lists are placed in (per-model schema,
142
- // else the plugin-level schema). When none is configured the Auth lists
143
- // stay in the default `public` schema and we leave the config untouched —
144
- // the greenfield default Prisma schema is unchanged (no `schemas`, no
145
- // `previewFeatures`, no `@@schema`).
146
84
  const authSchemas = Array.from(
147
85
  new Set(
148
86
  Object.values(normalized.models)
@@ -181,18 +119,13 @@ export function authPlugin(config: AuthConfig): Plugin {
181
119
  },
182
120
 
183
121
  runtime: (context, sudo) => {
184
- // Resolve the user list's context.db key from the configured user model.
185
- // context.db is keyed camelCase, so 'User' -> 'user', 'AuthUser' -> 'authUser'.
186
122
  const userDbKey = getDbKey(normalized.models.user.modelName)
187
123
 
188
- // Provide auth-related utilities at runtime
189
124
  return {
190
125
  /**
191
- * Get user by ID.
192
- *
193
- * Resolves through `sudo()` (per ADR-0013): the User list ships closed
194
- * by default, and "who is this session" must not depend on the
195
- * application's User access policy.
126
+ * Resolves through `sudo()` (per ADR-0013): the User list ships
127
+ * closed by default, and "who is this session" must not depend on
128
+ * the application's User access policy.
196
129
  */
197
130
  getUser: async (userId: string) => {
198
131
  return await sudo().db[userDbKey].findUnique({
@@ -200,10 +133,7 @@ export function authPlugin(config: AuthConfig): Plugin {
200
133
  })
201
134
  },
202
135
 
203
- /**
204
- * Get current user from session. Extracts userId from session and
205
- * fetches user data via `sudo()` — see {@link getUser}.
206
- */
136
+ /** See {@link getUser} — same `sudo()` rationale (ADR-0013), keyed off `context.session.userId`. */
207
137
  getCurrentUser: async () => {
208
138
  if (!context.session?.userId) {
209
139
  return null
@@ -1,4 +1,4 @@
1
- import type { ListConfig } from '@opensaas/stack-core'
1
+ import type { ListConfig, ListIndex } from '@opensaas/stack-core'
2
2
  import type { BetterAuthOptions, BetterAuthPlugin, User } from 'better-auth'
3
3
  import type { ExtendUserListConfig } from '../lists/index.js'
4
4
 
@@ -13,18 +13,12 @@ export type SendAuthEmail = (
13
13
  request?: Request,
14
14
  ) => Promise<void>
15
15
 
16
- /**
17
- * OAuth provider configuration
18
- */
19
16
  export type OAuthProvider = {
20
17
  clientId: string
21
18
  clientSecret: string
22
19
  enabled?: boolean
23
20
  }
24
21
 
25
- /**
26
- * Social provider configurations
27
- */
28
22
  export type SocialProvidersConfig = {
29
23
  github?: OAuthProvider
30
24
  google?: OAuthProvider
@@ -33,9 +27,6 @@ export type SocialProvidersConfig = {
33
27
  [key: string]: OAuthProvider | undefined
34
28
  }
35
29
 
36
- /**
37
- * Email and password configuration
38
- */
39
30
  export type EmailPasswordConfig = {
40
31
  enabled: boolean
41
32
  /**
@@ -76,9 +67,6 @@ export type EmailPasswordConfig = {
76
67
  sendResetPassword?: SendAuthEmail
77
68
  }
78
69
 
79
- /**
80
- * Email verification configuration
81
- */
82
70
  export type EmailVerificationConfig = {
83
71
  enabled: boolean
84
72
  /**
@@ -111,9 +99,6 @@ export type EmailVerificationConfig = {
111
99
  sendVerificationEmail?: SendAuthEmail
112
100
  }
113
101
 
114
- /**
115
- * Password reset configuration
116
- */
117
102
  export type PasswordResetConfig = {
118
103
  enabled: boolean
119
104
  /**
@@ -123,9 +108,6 @@ export type PasswordResetConfig = {
123
108
  tokenExpiration?: number
124
109
  }
125
110
 
126
- /**
127
- * Session configuration
128
- */
129
111
  export type SessionConfig = {
130
112
  /**
131
113
  * Session expiration in seconds
@@ -141,24 +123,6 @@ export type SessionConfig = {
141
123
  updateAge?: number | false
142
124
  }
143
125
 
144
- /**
145
- * Per-model better-auth configuration block.
146
- *
147
- * Mirrors better-auth's own `BetterAuthDBOptions` (the `user`/`session`/
148
- * `account`/`verification` config a developer already writes): `modelName`
149
- * renames the table/list and `fields` maps individual better-auth field names
150
- * to database column names. The auth plugin derives its Auth lists from this
151
- * config so the generated lists carry the same keys and column maps as the
152
- * developer's live better-auth tables.
153
- *
154
- * @example
155
- * ```typescript
156
- * authPlugin({
157
- * user: { modelName: 'AuthUser', fields: { name: 'full_name' } },
158
- * session: { modelName: 'AuthSession' },
159
- * })
160
- * ```
161
- */
162
126
  /**
163
127
  * App-authored operation + field-level access control for the Auth lists,
164
128
  * keyed by better-auth model name (not by the derived list key, so it stays
@@ -213,6 +177,24 @@ export type AuthAccessConfig = {
213
177
  rateLimit?: ListConfig<any>['access']
214
178
  }
215
179
 
180
+ /**
181
+ * Per-model better-auth configuration block.
182
+ *
183
+ * Mirrors better-auth's own `BetterAuthDBOptions` (the `user`/`session`/
184
+ * `account`/`verification` config a developer already writes): `modelName`
185
+ * renames the table/list and `fields` maps individual better-auth field names
186
+ * to database column names. The auth plugin derives its Auth lists from this
187
+ * config so the generated lists carry the same keys and column maps as the
188
+ * developer's live better-auth tables.
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * authPlugin({
193
+ * user: { modelName: 'AuthUser', fields: { name: 'full_name' } },
194
+ * session: { modelName: 'AuthSession' },
195
+ * })
196
+ * ```
197
+ */
216
198
  export type AuthModelConfig = {
217
199
  /**
218
200
  * The table/list name for this model.
@@ -262,35 +244,50 @@ export type AuthModelConfig = {
262
244
  * ```
263
245
  */
264
246
  schema?: string
247
+ /**
248
+ * App-authored model-level `@@unique`/`@@index` constraints for this auth
249
+ * model, in the same shape as a list's own {@link ListConfig.db} `indexes`
250
+ * (core's {@link ListIndex}). Entries name this model's own OpenSaaS field
251
+ * keys (e.g. `identifier`, `createdAt` on `verification`) — the same names
252
+ * used in this model's `fields` column map — not raw database column names.
253
+ *
254
+ * The stack already derives some indexes from better-auth's own table
255
+ * definitions (e.g. `User.email` is `@unique`). When an entry here covers a
256
+ * column that also carries a derived index, the derived index is
257
+ * suppressed for that column and only this entry is emitted — the
258
+ * application's declaration wins (ADR-0035). This is what makes adopting a
259
+ * live database's real constraint name, or extending a derived column into
260
+ * a composite index, expressible.
261
+ *
262
+ * @example Adopt a live `user.email` unique constraint under its real name
263
+ * ```typescript
264
+ * authPlugin({
265
+ * user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] },
266
+ * })
267
+ * ```
268
+ *
269
+ * @example Extend a derived index into a composite (per-identifier resend cooldown)
270
+ * ```typescript
271
+ * authPlugin({
272
+ * verification: {
273
+ * indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }],
274
+ * },
275
+ * })
276
+ * ```
277
+ */
278
+ indexes?: ListIndex[]
265
279
  }
266
280
 
267
- /**
268
- * Auth configuration options
269
- */
270
281
  export type AuthConfig = {
271
- /**
272
- * Email and password authentication
273
- */
274
282
  emailAndPassword?: EmailPasswordConfig | { enabled: true }
275
283
 
276
- /**
277
- * Email verification
278
- */
279
284
  emailVerification?: EmailVerificationConfig | { enabled: true }
280
285
 
281
- /**
282
- * Password reset
283
- */
284
286
  passwordReset?: PasswordResetConfig | { enabled: true }
285
287
 
286
- /**
287
- * OAuth/social providers
288
- */
289
288
  socialProviders?: SocialProvidersConfig
290
289
 
291
290
  /**
292
- * Session configuration.
293
- *
294
291
  * Carries session expiry settings as well as the better-auth `session` model
295
292
  * config (`modelName` + field column `fields` maps) used to derive the Auth
296
293
  * session list.
@@ -305,14 +302,8 @@ export type AuthConfig = {
305
302
  */
306
303
  user?: AuthModelConfig
307
304
 
308
- /**
309
- * better-auth `account` model configuration (modelName + field column maps).
310
- */
311
305
  account?: AuthModelConfig
312
306
 
313
- /**
314
- * better-auth `verification` model configuration (modelName + field column maps).
315
- */
316
307
  verification?: AuthModelConfig
317
308
 
318
309
  /**
@@ -402,16 +393,51 @@ export type AuthConfig = {
402
393
  */
403
394
  access?: AuthAccessConfig
404
395
 
396
+ /**
397
+ * Mark additional better-auth model fields as credentials, so they ship
398
+ * field-level read-denied like the stack's own seeded set (ADR-0036) — the
399
+ * six base-model fields plus, as of issue #1014, the `mcp`/oauth-provider
400
+ * plugin's `oauthClient.clientSecret`/`oauthAccessToken.token`/
401
+ * `oauthRefreshToken.token` and `twoFactor()`'s `twoFactor.secret`/
402
+ * `backupCodes`. Use this for a better-auth plugin the stack doesn't seed a
403
+ * credential set for.
404
+ *
405
+ * Keyed by better-auth's own **model key** (e.g. `'twoFactor'`,
406
+ * `'oauthClient'` — not the derived list key, so it stays remap-proof), each
407
+ * naming better-auth **field keys** (not mapped column names).
408
+ *
409
+ * Strictly additive: an entry can mark further fields as credentials, but
410
+ * can never unmark one of the stack's seeded fields — omitting or emptying
411
+ * a seeded model's list here leaves that model's seeded deny standing. An
412
+ * entry naming a field that doesn't exist on a model the app actually
413
+ * derives (has that plugin registered) throws, naming the model and field;
414
+ * an entry for a model the app doesn't derive at all is a silent no-op.
415
+ * An entry naming an id-referencing relationship field (e.g. an FK ending
416
+ * `Id`) also throws — that field derives to a `relationship()`, never a
417
+ * scalar column, so a deny registered against it could never apply.
418
+ *
419
+ * @example
420
+ * ```typescript
421
+ * authPlugin({
422
+ * betterAuthPlugins: [passkey()],
423
+ * credentialFields: { passkey: ['publicKey'] },
424
+ * })
425
+ * ```
426
+ */
427
+ credentialFields?: Record<string, string[]>
428
+
405
429
  /**
406
430
  * Additional Better Auth plugins to enable
407
431
  * Allows integrating any Better Auth plugin (MCP, 2FA, etc.)
408
432
  *
409
433
  * @example
410
434
  * ```typescript
411
- * import { mcp } from 'better-auth/plugins'
435
+ * import { mcp } from '@opensaas/stack-auth/plugins'
436
+ * import { jwt } from 'better-auth/plugins'
412
437
  *
413
438
  * betterAuthPlugins: [
414
- * mcp({ loginPage: '/sign-in' })
439
+ * jwt(), // required alongside mcp() since better-auth 1.7
440
+ * mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://your-app.com/api/mcp' })
415
441
  * ]
416
442
  * ```
417
443
  */
@@ -530,6 +556,8 @@ export type NormalizedAuthModelConfig = {
530
556
  tableName?: string
531
557
  fields: Record<string, string>
532
558
  schema?: string
559
+ /** App-authored `db.indexes` entries for this model (see {@link AuthModelConfig.indexes}). Defaults to `[]`. */
560
+ indexes?: ListIndex[]
533
561
  }
534
562
 
535
563
  /**
@@ -550,10 +578,6 @@ export type NormalizedAuthModels = {
550
578
  rateLimit?: NormalizedAuthModelConfig
551
579
  }
552
580
 
553
- /**
554
- * Internal normalized auth configuration
555
- * Used after parsing user config
556
- */
557
581
  export type NormalizedAuthConfig = Required<
558
582
  Omit<
559
583
  AuthConfig,
@@ -574,7 +598,6 @@ export type NormalizedAuthConfig = Required<
574
598
  passwordReset: Required<PasswordResetConfig>
575
599
  /** Resolved session expiry settings (model config lives under `models.session`). */
576
600
  session: Required<SessionConfig>
577
- /** Resolved better-auth model config (modelName + field column maps + schema) for all auth models. */
578
601
  models: NormalizedAuthModels
579
602
  /**
580
603
  * Plugin-level Postgres schema for the Auth lists, if any. Resolved per-model
package/src/index.ts CHANGED
@@ -1,13 +1,5 @@
1
1
  /**
2
- * @opensaas/stack-auth
3
- *
4
- * Better-auth integration for OpenSaas Stack
5
- *
6
- * This package provides:
7
- * - Auto-generated User, Session, Account, Verification lists
8
- * - Session integration with OpenSaas access control
9
- * - Pre-built auth UI components (SignIn, SignUp, ForgotPassword)
10
- * - Easy configuration with authPlugin()
2
+ * @opensaas/stack-auth — Better-auth integration for OpenSaas Stack.
11
3
  *
12
4
  * @example
13
5
  * ```typescript
@@ -28,7 +20,6 @@
28
20
  * ```
29
21
  */
30
22
 
31
- // Config exports
32
23
  export { normalizeAuthConfig } from './config/index.js'
33
24
  export { authPlugin } from './config/plugin.js'
34
25
  export type { AuthConfig, NormalizedAuthConfig } from './config/index.js'
@@ -38,16 +29,14 @@ export type * from './config/types.js'
38
29
  export { deriveAuthLists } from './config/derive-auth-lists.js'
39
30
  export type { DerivedAuthLists } from './config/derive-auth-lists.js'
40
31
 
41
- // "Adopt existing better-auth tables" recipe — sets the model/schema knobs that
42
- // match a pre-existing separate-schema better-auth install so a migrating
43
- // project reaches Schema parity without rebuilding the config by hand.
32
+ // "Adopt existing better-auth tables" recipe — presets the model/schema knobs
33
+ // for a pre-existing separate-schema better-auth install (advanced use case).
44
34
  export { adoptBetterAuthTables } from './config/adopt-better-auth-tables.js'
45
35
  export type {
46
36
  AdoptBetterAuthTablesOptions,
47
37
  AdoptBetterAuthTablesConfig,
48
38
  } from './config/adopt-better-auth-tables.js'
49
39
 
50
- // Runtime type exports
51
40
  export type { AuthRuntimeServices } from './runtime/types.js'
52
41
 
53
42
  // List generators (for advanced use cases)
@@ -1,4 +1,5 @@
1
1
  import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
2
+ import type { BetterAuthPlugin } from 'better-auth'
2
3
  import type { AuthAccessConfig, NormalizedAuthModels } from '../config/types.js'
3
4
  import { deriveAuthLists } from '../config/derive-auth-lists.js'
4
5
 
@@ -42,8 +43,6 @@ const DEFAULT_MODELS: NormalizedAuthModels = {
42
43
  }
43
44
 
44
45
  /**
45
- * Create the base User list with better-auth required fields.
46
- *
47
46
  * Backwards-compatible helper: derives the default `User` list (keyed `User`,
48
47
  * default field shapes) via {@link deriveAuthLists}.
49
48
  */
@@ -79,7 +78,9 @@ export function createVerificationList(): ListConfig<any> {
79
78
  }
80
79
 
81
80
  /**
82
- * Get all auth lists required by better-auth.
81
+ * Get all auth lists required by better-auth — the four base models, an
82
+ * optional database-backed `RateLimit`, and any table a better-auth plugin
83
+ * declares in its own `schema` (issue #992).
83
84
  *
84
85
  * Derives the Auth lists from the resolved better-auth model config. When no
85
86
  * `models` are supplied (or none carry overrides), the result is the historical
@@ -87,13 +88,24 @@ export function createVerificationList(): ListConfig<any> {
87
88
  *
88
89
  * @param userConfig - Extra User-list fields/access/hooks (from `extendUserList`)
89
90
  * @param models - Resolved better-auth model config; defaults to the better-auth defaults
90
- * @param accessConfig - App-authored access for each Auth list, keyed by better-auth model name
91
+ * @param accessConfig - App-authored access for each base Auth list, keyed by better-auth model name
92
+ * @param plugins - The app's better-auth plugins (`authPlugin({ betterAuthPlugins })`)
93
+ * @param credentialFieldsConfig - App-authored additions to the credential-field read-deny
94
+ * (`authPlugin({ credentialFields })`), keyed by better-auth model key
91
95
  */
92
96
  export function getAuthLists(
93
97
  userConfig?: ExtendUserListConfig,
94
98
  models: NormalizedAuthModels = DEFAULT_MODELS,
95
99
  accessConfig?: AuthAccessConfig,
100
+ plugins?: BetterAuthPlugin[],
101
+ credentialFieldsConfig?: Record<string, string[]>,
96
102
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
97
103
  ): Record<string, ListConfig<any>> {
98
- return deriveAuthLists(models, userConfig || {}, accessConfig || {}).lists
104
+ return deriveAuthLists(
105
+ models,
106
+ userConfig || {},
107
+ accessConfig || {},
108
+ plugins || [],
109
+ credentialFieldsConfig || {},
110
+ ).lists
99
111
  }