@opensaas/stack-auth 0.21.0 → 0.22.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 (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +90 -0
  3. package/CLAUDE.md +98 -0
  4. package/README.md +33 -0
  5. package/dist/config/adopt-better-auth-tables.d.ts +107 -0
  6. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -0
  7. package/dist/config/adopt-better-auth-tables.js +70 -0
  8. package/dist/config/adopt-better-auth-tables.js.map +1 -0
  9. package/dist/config/derive-auth-lists.d.ts +50 -0
  10. package/dist/config/derive-auth-lists.d.ts.map +1 -0
  11. package/dist/config/derive-auth-lists.js +274 -0
  12. package/dist/config/derive-auth-lists.js.map +1 -0
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +43 -0
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/plugin.d.ts.map +1 -1
  17. package/dist/config/plugin.js +52 -9
  18. package/dist/config/plugin.js.map +1 -1
  19. package/dist/config/types.d.ts +130 -3
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/index.d.ts +4 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +6 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/lists/index.d.ts +17 -11
  26. package/dist/lists/index.d.ts.map +1 -1
  27. package/dist/lists/index.js +34 -208
  28. package/dist/lists/index.js.map +1 -1
  29. package/dist/server/index.d.ts.map +1 -1
  30. package/dist/server/index.js +28 -7
  31. package/dist/server/index.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/config/adopt-better-auth-tables.ts +146 -0
  34. package/src/config/derive-auth-lists.ts +323 -0
  35. package/src/config/index.ts +58 -0
  36. package/src/config/plugin.ts +66 -9
  37. package/src/config/types.ts +146 -3
  38. package/src/index.ts +13 -0
  39. package/src/lists/index.ts +42 -202
  40. package/src/server/index.ts +31 -9
  41. package/tests/adopt-better-auth-tables.test.ts +183 -0
  42. package/tests/derive-auth-lists.test.ts +232 -0
  43. package/tests/plugin-derived-keys.test.ts +138 -0
  44. package/tests/plugin-schema-placement.test.ts +121 -0
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -82,6 +82,55 @@ export type SessionConfig = {
82
82
  updateAge?: boolean
83
83
  }
84
84
 
85
+ /**
86
+ * Per-model better-auth configuration block.
87
+ *
88
+ * Mirrors better-auth's own `BetterAuthDBOptions` (the `user`/`session`/
89
+ * `account`/`verification` config a developer already writes): `modelName`
90
+ * renames the table/list and `fields` maps individual better-auth field names
91
+ * to database column names. The auth plugin derives its Auth lists from this
92
+ * config so the generated lists carry the same keys and column maps as the
93
+ * developer's live better-auth tables.
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * authPlugin({
98
+ * user: { modelName: 'AuthUser', fields: { name: 'full_name' } },
99
+ * session: { modelName: 'AuthSession' },
100
+ * })
101
+ * ```
102
+ */
103
+ export type AuthModelConfig = {
104
+ /**
105
+ * The table/list name for this model.
106
+ * Becomes the OpenSaaS list key (and Prisma model name) and the table `@@map`.
107
+ * @default the default better-auth model name (e.g. 'User', 'Session')
108
+ */
109
+ modelName?: string
110
+ /**
111
+ * Map better-auth field names to database column names.
112
+ * Each entry generates a `@map("column")` on the derived field.
113
+ *
114
+ * @example
115
+ * ```typescript
116
+ * fields: { name: 'full_name', emailVerified: 'email_verified' }
117
+ * ```
118
+ */
119
+ fields?: Record<string, string>
120
+ /**
121
+ * Database schema (Postgres) for this auth model.
122
+ * Generates a `@@schema("...")` on the derived list, overriding the
123
+ * plugin-level {@link AuthConfig.schema} for this one model.
124
+ *
125
+ * @example
126
+ * ```typescript
127
+ * // Place the verification table in a different schema from the rest
128
+ * verification: { schema: 'auth_internal' }
129
+ * ```
130
+ */
131
+ schema?: string
132
+ }
133
+
85
134
  /**
86
135
  * Auth configuration options
87
136
  */
@@ -107,9 +156,60 @@ export type AuthConfig = {
107
156
  socialProviders?: SocialProvidersConfig
108
157
 
109
158
  /**
110
- * Session configuration
159
+ * Session configuration.
160
+ *
161
+ * Carries session expiry settings as well as the better-auth `session` model
162
+ * config (`modelName` + field column `fields` maps) used to derive the Auth
163
+ * session list.
164
+ */
165
+ session?: SessionConfig & AuthModelConfig
166
+
167
+ /**
168
+ * better-auth `user` model configuration (modelName + field column maps).
169
+ * Used to derive the Auth user list's key, table `@@map`, and field `@map`s.
170
+ *
171
+ * Custom fields beyond the better-auth basics are added via `extendUserList`.
172
+ */
173
+ user?: AuthModelConfig
174
+
175
+ /**
176
+ * better-auth `account` model configuration (modelName + field column maps).
177
+ */
178
+ account?: AuthModelConfig
179
+
180
+ /**
181
+ * better-auth `verification` model configuration (modelName + field column maps).
182
+ */
183
+ verification?: AuthModelConfig
184
+
185
+ /**
186
+ * Database schema (Postgres) for the generated Auth lists.
187
+ *
188
+ * When set, all four Auth lists (user/session/account/verification) are placed
189
+ * in this schema via `@@schema(...)`, and the stack's multi-schema support is
190
+ * wired automatically: the datasource `schemas` array gains this schema (plus
191
+ * `public`) and the `multiSchema` preview feature is enabled. A per-model
192
+ * {@link AuthModelConfig.schema} overrides this for an individual list.
193
+ *
194
+ * Useful for adopting an existing separate-schema better-auth installation
195
+ * (e.g. an `auth` Postgres schema) so the generated lists diff clean against
196
+ * the live tables. When unset, the Auth lists stay in the default `public`
197
+ * schema and no `@@schema` is emitted (greenfield default unchanged).
198
+ *
199
+ * Only applies to the `postgresql` provider.
200
+ *
201
+ * @example Adopt an `auth`-schema better-auth install
202
+ * ```typescript
203
+ * authPlugin({
204
+ * schema: 'auth',
205
+ * user: { modelName: 'AuthUser' },
206
+ * session: { modelName: 'AuthSession' },
207
+ * account: { modelName: 'AuthAccount' },
208
+ * verification: { modelName: 'AuthVerification' },
209
+ * })
210
+ * ```
111
211
  */
112
- session?: SessionConfig
212
+ schema?: string
113
213
 
114
214
  /**
115
215
  * Which fields to include in the session object
@@ -202,6 +302,30 @@ export type AuthConfig = {
202
302
  }
203
303
  }
204
304
 
305
+ /**
306
+ * Resolved per-model auth configuration after normalization.
307
+ * Always carries a concrete `modelName` (the developer's override or the
308
+ * better-auth default) and a (possibly empty) `fields` column map. `schema`
309
+ * carries the resolved Postgres schema for the model (per-model override, else
310
+ * the plugin-level schema, else `undefined` for the default `public` schema).
311
+ */
312
+ export type NormalizedAuthModelConfig = {
313
+ modelName: string
314
+ fields: Record<string, string>
315
+ schema?: string
316
+ }
317
+
318
+ /**
319
+ * Resolved auth model configuration for all four better-auth models.
320
+ * Consumed by the Auth-list derivation and the runtime user-key resolution.
321
+ */
322
+ export type NormalizedAuthModels = {
323
+ user: NormalizedAuthModelConfig
324
+ session: NormalizedAuthModelConfig
325
+ account: NormalizedAuthModelConfig
326
+ verification: NormalizedAuthModelConfig
327
+ }
328
+
205
329
  /**
206
330
  * Internal normalized auth configuration
207
331
  * Used after parsing user config
@@ -209,12 +333,31 @@ export type AuthConfig = {
209
333
  export type NormalizedAuthConfig = Required<
210
334
  Omit<
211
335
  AuthConfig,
212
- 'emailAndPassword' | 'emailVerification' | 'passwordReset' | 'betterAuthPlugins' | 'rateLimit'
336
+ | 'emailAndPassword'
337
+ | 'emailVerification'
338
+ | 'passwordReset'
339
+ | 'betterAuthPlugins'
340
+ | 'rateLimit'
341
+ | 'session'
342
+ | 'user'
343
+ | 'account'
344
+ | 'verification'
345
+ | 'schema'
213
346
  >
214
347
  > & {
215
348
  emailAndPassword: Required<EmailPasswordConfig>
216
349
  emailVerification: Required<EmailVerificationConfig>
217
350
  passwordReset: Required<PasswordResetConfig>
351
+ /** Resolved session expiry settings (model config lives under `models.session`). */
352
+ session: Required<SessionConfig>
353
+ /** Resolved better-auth model config (modelName + field column maps + schema) for all auth models. */
354
+ models: NormalizedAuthModels
355
+ /**
356
+ * Plugin-level Postgres schema for the Auth lists, if any. Resolved per-model
357
+ * schemas live on `models.<model>.schema`; this is the unresolved plugin-level
358
+ * default (used to wire the datasource `schemas` array during generation).
359
+ */
360
+ schema?: string
218
361
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth plugin types are not exposed, must use any
219
362
  betterAuthPlugins: any[]
220
363
  rateLimit?: {
package/src/index.ts CHANGED
@@ -34,6 +34,19 @@ export { authPlugin } from './config/plugin.js'
34
34
  export type { AuthConfig, NormalizedAuthConfig } from './config/index.js'
35
35
  export type * from './config/types.js'
36
36
 
37
+ // Pure better-auth config -> Auth lists derivation (advanced use cases)
38
+ export { deriveAuthLists } from './config/derive-auth-lists.js'
39
+ export type { DerivedAuthLists } from './config/derive-auth-lists.js'
40
+
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.
44
+ export { adoptBetterAuthTables } from './config/adopt-better-auth-tables.js'
45
+ export type {
46
+ AdoptBetterAuthTablesOptions,
47
+ AdoptBetterAuthTablesConfig,
48
+ } from './config/adopt-better-auth-tables.js'
49
+
37
50
  // Runtime type exports
38
51
  export type { AuthRuntimeServices } from './runtime/types.js'
39
52
 
@@ -1,6 +1,6 @@
1
- import { list } from '@opensaas/stack-core'
2
- import { text, timestamp, checkbox, relationship } from '@opensaas/stack-core/fields'
3
1
  import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
2
+ import type { NormalizedAuthModels } from '../config/types.js'
3
+ import { deriveAuthLists } from '../config/derive-auth-lists.js'
4
4
 
5
5
  /**
6
6
  * Configuration for extending the auto-generated User list
@@ -25,229 +25,69 @@ export type ExtendUserListConfig = {
25
25
  }
26
26
 
27
27
  /**
28
- * Create the base User list with better-auth required fields
29
- * This matches the better-auth user schema
28
+ * The default better-auth model config (no `modelName`/`fields` overrides).
29
+ * Produces the historical `User`/`Session`/`Account`/`Verification` keys with
30
+ * their original field shapes. Used by the backwards-compatible
31
+ * `createUserList`/`getAuthLists` helpers.
32
+ */
33
+ const DEFAULT_MODELS: NormalizedAuthModels = {
34
+ user: { modelName: 'User', fields: {} },
35
+ session: { modelName: 'Session', fields: {} },
36
+ account: { modelName: 'Account', fields: {} },
37
+ verification: { modelName: 'Verification', fields: {} },
38
+ }
39
+
40
+ /**
41
+ * Create the base User list with better-auth required fields.
42
+ *
43
+ * Backwards-compatible helper: derives the default `User` list (keyed `User`,
44
+ * default field shapes) via {@link deriveAuthLists}.
30
45
  */
31
46
  export function createUserList(
32
- config?: ExtendUserListConfig, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
47
+ config?: ExtendUserListConfig,
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
33
49
  ): ListConfig<any> {
34
- return list({
35
- fields: {
36
- // Better-auth required fields
37
- name: text({
38
- validation: { isRequired: true },
39
- }),
40
- email: text({
41
- validation: { isRequired: true },
42
- isIndexed: 'unique',
43
- }),
44
- emailVerified: checkbox({
45
- defaultValue: false,
46
- }),
47
- image: text(),
48
-
49
- // Relationships to other auth tables
50
- sessions: relationship({
51
- ref: 'Session.user',
52
- many: true,
53
- }),
54
- accounts: relationship({
55
- ref: 'Account.user',
56
- many: true,
57
- }),
58
-
59
- // Custom fields from user config
60
- ...(config?.fields || {}),
61
- },
62
- access: config?.access || {
63
- operation: {
64
- // Anyone can query users (for displaying names, etc.)
65
- query: () => true,
66
- // Anyone can create a user (sign up)
67
- create: () => true,
68
- // Only update your own user record
69
- update: ({ session, item }) => {
70
- if (!session) return false
71
- const userId = (session as { userId?: string }).userId
72
- const itemId = (item as { id?: string })?.id
73
- return userId === itemId
74
- },
75
- // Only delete your own user record
76
- delete: ({ session, item }) => {
77
- if (!session) return false
78
- const userId = (session as { userId?: string }).userId
79
- const itemId = (item as { id?: string })?.id
80
- return userId === itemId
81
- },
82
- },
83
- },
84
- hooks: config?.hooks,
85
- })
50
+ return deriveAuthLists(DEFAULT_MODELS, config).lists.User
86
51
  }
87
52
 
88
53
  /**
89
- * Create the Session list for better-auth
90
- * Stores active user sessions
54
+ * Create the Session list for better-auth (default `Session` key).
91
55
  */
92
56
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
93
57
  export function createSessionList(): ListConfig<any> {
94
- return list({
95
- fields: {
96
- // Session token (stored in cookie, used as primary key)
97
- token: text({
98
- validation: { isRequired: true },
99
- isIndexed: 'unique',
100
- }),
101
- // Expiration timestamp
102
- expiresAt: timestamp(),
103
- // Optional: IP address for security
104
- ipAddress: text(),
105
- // Optional: User agent for security
106
- userAgent: text(),
107
- // Relationship to user (userId will be auto-generated)
108
- user: relationship({
109
- ref: 'User.sessions',
110
- }),
111
- },
112
- access: {
113
- operation: {
114
- // Only the session owner can query their sessions
115
- query: ({ session }) => {
116
- if (!session) return false
117
- const userId = (session as { userId?: string }).userId
118
- if (!userId) return false
119
- // Return Prisma filter for nested relationship
120
- return {
121
- user: {
122
- id: { equals: userId },
123
- },
124
- } as Record<string, unknown>
125
- },
126
- // Better-auth handles session creation
127
- create: () => true,
128
- // No manual updates
129
- update: () => false,
130
- // Better-auth handles session deletion (logout)
131
- delete: ({ session, item }) => {
132
- if (!session) return false
133
- const userId = (session as { userId?: string }).userId
134
- const itemUserId = (item as { user?: { id?: string } })?.user?.id
135
- return userId === itemUserId
136
- },
137
- },
138
- },
139
- })
58
+ return deriveAuthLists(DEFAULT_MODELS).lists.Session
140
59
  }
141
60
 
142
61
  /**
143
- * Create the Account list for better-auth
144
- * Stores OAuth provider accounts and credentials
62
+ * Create the Account list for better-auth (default `Account` key).
145
63
  */
146
64
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
147
65
  export function createAccountList(): ListConfig<any> {
148
- return list({
149
- fields: {
150
- // Account identifier from provider
151
- accountId: text({
152
- validation: { isRequired: true },
153
- }),
154
- // Provider identifier (e.g., 'github', 'google', 'credentials')
155
- providerId: text({
156
- validation: { isRequired: true },
157
- }),
158
- // Relationship to user (userId will be auto-generated)
159
- user: relationship({
160
- ref: 'User.accounts',
161
- }),
162
- // OAuth tokens
163
- accessToken: text(),
164
- refreshToken: text(),
165
- accessTokenExpiresAt: timestamp(),
166
- refreshTokenExpiresAt: timestamp(),
167
- scope: text(),
168
- idToken: text(),
169
- // Password hash for credential provider (better-auth stores in account table)
170
- password: text(),
171
- },
172
- access: {
173
- operation: {
174
- // Only the account owner can query their accounts
175
- query: ({ session }) => {
176
- if (!session) return false
177
- const userId = (session as { userId?: string }).userId
178
- if (!userId) return false
179
- // Return Prisma filter for nested relationship
180
- return {
181
- user: {
182
- id: { equals: userId },
183
- },
184
- } as Record<string, unknown>
185
- },
186
- // Better-auth handles account creation
187
- create: () => true,
188
- // Better-auth handles account updates (token refresh)
189
- update: ({ session, item }) => {
190
- if (!session) return false
191
- const userId = (session as { userId?: string }).userId
192
- const itemUserId = (item as { user?: { id?: string } })?.user?.id
193
- return userId === itemUserId
194
- },
195
- // Account owner can delete their accounts
196
- delete: ({ session, item }) => {
197
- if (!session) return false
198
- const userId = (session as { userId?: string }).userId
199
- const itemUserId = (item as { user?: { id?: string } })?.user?.id
200
- return userId === itemUserId
201
- },
202
- },
203
- },
204
- })
66
+ return deriveAuthLists(DEFAULT_MODELS).lists.Account
205
67
  }
206
68
 
207
69
  /**
208
- * Create the Verification list for better-auth
209
- * Stores email verification tokens, password reset tokens, etc.
70
+ * Create the Verification list for better-auth (default `Verification` key).
210
71
  */
211
72
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
212
73
  export function createVerificationList(): ListConfig<any> {
213
- return list({
214
- fields: {
215
- // Identifier (e.g., email address)
216
- identifier: text({
217
- validation: { isRequired: true },
218
- }),
219
- // Token value
220
- value: text({
221
- validation: { isRequired: true },
222
- }),
223
- // Expiration timestamp
224
- expiresAt: timestamp(),
225
- },
226
- access: {
227
- operation: {
228
- // No public querying (better-auth handles verification internally)
229
- query: () => false,
230
- // Better-auth creates verification tokens
231
- create: () => true,
232
- // No updates
233
- update: () => false,
234
- // Better-auth deletes used/expired tokens
235
- delete: () => true,
236
- },
237
- },
238
- })
74
+ return deriveAuthLists(DEFAULT_MODELS).lists.Verification
239
75
  }
240
76
 
241
77
  /**
242
- * Get all auth lists required by better-auth
243
- * This is the main export used by authPlugin()
78
+ * Get all auth lists required by better-auth.
79
+ *
80
+ * Derives the Auth lists from the resolved better-auth model config. When no
81
+ * `models` are supplied (or none carry overrides), the result is the historical
82
+ * default set keyed `User`/`Session`/`Account`/`Verification`.
83
+ *
84
+ * @param userConfig - Extra User-list fields/access/hooks (from `extendUserList`)
85
+ * @param models - Resolved better-auth model config; defaults to the better-auth defaults
244
86
  */
245
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
246
- export function getAuthLists(userConfig?: ExtendUserListConfig): Record<string, ListConfig<any>> {
247
- return {
248
- User: createUserList(userConfig),
249
- Session: createSessionList(),
250
- Account: createAccountList(),
251
- Verification: createVerificationList(),
252
- }
87
+ export function getAuthLists(
88
+ userConfig?: ExtendUserListConfig,
89
+ models: NormalizedAuthModels = DEFAULT_MODELS,
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
91
+ ): Record<string, ListConfig<any>> {
92
+ return deriveAuthLists(models, userConfig || {}).lists
253
93
  }
@@ -3,7 +3,7 @@ import { prismaAdapter } from 'better-auth/adapters/prisma'
3
3
  import type { BetterAuthOptions } from 'better-auth'
4
4
  import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
5
5
  import type { DatabaseConfig } from '@opensaas/stack-core/internal'
6
- import type { NormalizedAuthConfig } from '../config/types.js'
6
+ import type { NormalizedAuthConfig, NormalizedAuthModelConfig } from '../config/types.js'
7
7
 
8
8
  /**
9
9
  * Get better-auth database configuration from OpenSaas config
@@ -17,6 +17,22 @@ function getDatabaseConfig(
17
17
  })
18
18
  }
19
19
 
20
+ /**
21
+ * Translate a normalized OpenSaaS auth model config into the better-auth
22
+ * per-model options (`modelName` + `fields` column map). Returns `undefined`
23
+ * when there is nothing to override so the running auth instance keeps
24
+ * better-auth's own defaults untouched.
25
+ */
26
+ function toBetterAuthModelOptions(
27
+ model: NormalizedAuthModelConfig,
28
+ ): { modelName?: string; fields?: Record<string, string> } | undefined {
29
+ const hasFields = Object.keys(model.fields).length > 0
30
+ const options: { modelName?: string; fields?: Record<string, string> } = {}
31
+ if (model.modelName) options.modelName = model.modelName
32
+ if (hasFields) options.fields = model.fields
33
+ return Object.keys(options).length > 0 ? options : undefined
34
+ }
35
+
20
36
  /**
21
37
  * Create a better-auth instance from OpenSaas config
22
38
  * This should be called once at app startup
@@ -64,6 +80,20 @@ export function createAuth(
64
80
  const betterAuthConfig: BetterAuthOptions = {
65
81
  database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
66
82
 
83
+ // Mirror the per-model config (modelName + field column maps) back to
84
+ // better-auth so the running auth instance reads/writes the same
85
+ // tables/columns the OpenSaaS Auth lists were derived from.
86
+ user: toBetterAuthModelOptions(authConfig.models.user),
87
+ session: {
88
+ ...toBetterAuthModelOptions(authConfig.models.session),
89
+ expiresIn: authConfig.session.expiresIn || 604800,
90
+ updateAge: authConfig.session.updateAge
91
+ ? (authConfig.session.expiresIn || 604800) / 10
92
+ : 0,
93
+ },
94
+ account: toBetterAuthModelOptions(authConfig.models.account),
95
+ verification: toBetterAuthModelOptions(authConfig.models.verification),
96
+
67
97
  // Enable email and password if configured
68
98
  emailAndPassword: authConfig.emailAndPassword.enabled
69
99
  ? {
@@ -72,14 +102,6 @@ export function createAuth(
72
102
  }
73
103
  : undefined,
74
104
 
75
- // Configure session
76
- session: {
77
- expiresIn: authConfig.session.expiresIn || 604800,
78
- updateAge: authConfig.session.updateAge
79
- ? (authConfig.session.expiresIn || 604800) / 10
80
- : 0,
81
- },
82
-
83
105
  // Trust host (required for production)
84
106
  trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
85
107