@opensaas/stack-auth 0.1.0 → 0.1.2

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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Convert Better Auth database schema to OpenSaaS list configs
3
+ * Allows dynamic list generation from Better Auth plugins
4
+ */
5
+
6
+ import { list } from '@opensaas/stack-core'
7
+ import { text, timestamp, checkbox, integer } from '@opensaas/stack-core/fields'
8
+ import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
9
+
10
+ /**
11
+ * Better Auth field attribute structure
12
+ * Inferred from better-auth internal types
13
+ */
14
+ type BetterAuthFieldAttribute = {
15
+ type: string // 'string' | 'number' | 'boolean' | 'date' | etc.
16
+ required?: boolean
17
+ unique?: boolean
18
+ references?: {
19
+ model: string
20
+ field: string
21
+ onDelete?: 'cascade' | 'set null' | 'restrict'
22
+ }
23
+ defaultValue?: unknown
24
+ returned?: boolean
25
+ input?: boolean
26
+ }
27
+
28
+ /**
29
+ * Better Auth table schema structure
30
+ */
31
+ type BetterAuthTableSchema = {
32
+ modelName: string
33
+ fields: Record<string, BetterAuthFieldAttribute>
34
+ }
35
+
36
+ /**
37
+ * Convert Better Auth field type to OpenSaaS field config
38
+ */
39
+ function convertField(
40
+ fieldName: string,
41
+ betterAuthField: BetterAuthFieldAttribute,
42
+ ): FieldConfig | null {
43
+ const { type, required, unique, defaultValue, references } = betterAuthField
44
+
45
+ // System fields are auto-generated by OpenSaaS
46
+ if (fieldName === 'id' || fieldName === 'createdAt' || fieldName === 'updatedAt') {
47
+ return null
48
+ }
49
+
50
+ // Handle references (relationships)
51
+ if (references) {
52
+ // Relationships are handled separately
53
+ // Return null here and handle in relationship pass
54
+ return null
55
+ }
56
+
57
+ // Map Better Auth types to OpenSaaS field types
58
+ switch (type) {
59
+ case 'string':
60
+ return text({
61
+ validation: { isRequired: required },
62
+ isIndexed: unique ? 'unique' : undefined,
63
+ defaultValue: typeof defaultValue === 'string' ? defaultValue : undefined,
64
+ })
65
+
66
+ case 'number':
67
+ return integer({
68
+ validation: { isRequired: required },
69
+ defaultValue: typeof defaultValue === 'number' ? defaultValue : undefined,
70
+ })
71
+
72
+ case 'boolean':
73
+ return checkbox({
74
+ defaultValue: typeof defaultValue === 'boolean' ? defaultValue : false,
75
+ })
76
+
77
+ case 'date':
78
+ return timestamp({
79
+ defaultValue: defaultValue === 'now' ? { kind: 'now' } : undefined,
80
+ })
81
+
82
+ default:
83
+ // Unknown type - default to text
84
+ console.warn(
85
+ `[stack-auth] Unknown Better Auth field type "${type}" for field "${fieldName}", defaulting to text field`,
86
+ )
87
+ return text({
88
+ validation: { isRequired: required },
89
+ })
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Get default access control for auth tables
95
+ * Most auth tables should only be accessible to their owners
96
+ */
97
+ function getDefaultAccess(tableName: string): ListConfig['access'] {
98
+ const lowerTableName = tableName.toLowerCase()
99
+
100
+ // User table - special access control
101
+ if (lowerTableName === 'user') {
102
+ return {
103
+ operation: {
104
+ query: () => true, // Anyone can query users
105
+ create: () => true, // Anyone can create (sign up)
106
+ update: ({ session, item }) => {
107
+ if (!session) return false
108
+ const userId = (session as { userId?: string }).userId
109
+ const itemId = (item as { id?: string })?.id
110
+ return userId === itemId
111
+ },
112
+ delete: ({ session, item }) => {
113
+ if (!session) return false
114
+ const userId = (session as { userId?: string }).userId
115
+ const itemId = (item as { id?: string })?.id
116
+ return userId === itemId
117
+ },
118
+ },
119
+ }
120
+ }
121
+
122
+ // Session table
123
+ if (lowerTableName === 'session') {
124
+ return {
125
+ operation: {
126
+ query: ({ session }) => {
127
+ if (!session) return false
128
+ const userId = (session as { userId?: string }).userId
129
+ if (!userId) return false
130
+ return {
131
+ user: { id: { equals: userId } },
132
+ } as Record<string, unknown>
133
+ },
134
+ create: () => true, // Better-auth handles session creation
135
+ update: () => false, // No manual updates
136
+ delete: ({ session, item }) => {
137
+ if (!session) return false
138
+ const userId = (session as { userId?: string }).userId
139
+ const itemUserId = (item as { user?: { id?: string } })?.user?.id
140
+ return userId === itemUserId
141
+ },
142
+ },
143
+ }
144
+ }
145
+
146
+ // Account table
147
+ if (lowerTableName === 'account') {
148
+ return {
149
+ operation: {
150
+ query: ({ session }) => {
151
+ if (!session) return false
152
+ const userId = (session as { userId?: string }).userId
153
+ if (!userId) return false
154
+ return {
155
+ user: { id: { equals: userId } },
156
+ } as Record<string, unknown>
157
+ },
158
+ create: () => true, // Better-auth handles account creation
159
+ update: ({ session, item }) => {
160
+ if (!session) return false
161
+ const userId = (session as { userId?: string }).userId
162
+ const itemUserId = (item as { user?: { id?: string } })?.user?.id
163
+ return userId === itemUserId
164
+ },
165
+ delete: ({ session, item }) => {
166
+ if (!session) return false
167
+ const userId = (session as { userId?: string }).userId
168
+ const itemUserId = (item as { user?: { id?: string } })?.user?.id
169
+ return userId === itemUserId
170
+ },
171
+ },
172
+ }
173
+ }
174
+
175
+ // Verification table
176
+ if (lowerTableName === 'verification') {
177
+ return {
178
+ operation: {
179
+ query: () => false, // No public querying
180
+ create: () => true, // Better-auth creates verification tokens
181
+ update: () => false, // No updates
182
+ delete: () => true, // Better-auth deletes used/expired tokens
183
+ },
184
+ }
185
+ }
186
+
187
+ // OAuth tables (from MCP/OIDC plugins)
188
+ if (
189
+ lowerTableName === 'oauthapplication' ||
190
+ lowerTableName === 'oauthaccesstoken' ||
191
+ lowerTableName === 'oauthconsent'
192
+ ) {
193
+ return {
194
+ operation: {
195
+ query: ({ session }) => {
196
+ if (!session) return false
197
+ const userId = (session as { userId?: string }).userId
198
+ if (!userId) return false
199
+ // Filter by userId if field exists
200
+ return {
201
+ userId: { equals: userId },
202
+ } as Record<string, unknown>
203
+ },
204
+ create: () => true, // Better-auth/plugins handle creation
205
+ update: ({ session, item }) => {
206
+ if (!session) return false
207
+ const userId = (session as { userId?: string }).userId
208
+ const itemUserId = (item as { userId?: string })?.userId
209
+ return userId === itemUserId
210
+ },
211
+ delete: ({ session, item }) => {
212
+ if (!session) return false
213
+ const userId = (session as { userId?: string }).userId
214
+ const itemUserId = (item as { userId?: string })?.userId
215
+ return userId === itemUserId
216
+ },
217
+ },
218
+ }
219
+ }
220
+
221
+ // Default: restrict all access (safest default)
222
+ return {
223
+ operation: {
224
+ query: () => false,
225
+ create: () => false,
226
+ update: () => false,
227
+ delete: () => false,
228
+ },
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Convert Better Auth table schema to OpenSaaS ListConfig
234
+ */
235
+ export function convertTableToList(
236
+ tableName: string,
237
+ tableSchema: BetterAuthTableSchema,
238
+ ): ListConfig {
239
+ const fields: Record<string, FieldConfig> = {}
240
+
241
+ // First pass: convert regular fields
242
+ for (const [fieldName, fieldAttr] of Object.entries(tableSchema.fields)) {
243
+ const fieldConfig = convertField(fieldName, fieldAttr)
244
+ if (fieldConfig) {
245
+ fields[fieldName] = fieldConfig
246
+ }
247
+ }
248
+
249
+ // Second pass: add relationships
250
+ // NOTE: Better Auth uses one-way references, but OpenSaaS requires bidirectional relationships
251
+ // We skip relationship conversion for now and rely on the foreign key fields
252
+ // Users can manually add bidirectional relationships if needed by extending the User list
253
+ for (const [fieldName, fieldAttr] of Object.entries(tableSchema.fields)) {
254
+ if (fieldAttr.references) {
255
+ // For now, treat reference fields as regular text fields (foreign keys)
256
+ // This preserves the userId, clientId, etc. fields that Better Auth expects
257
+ if (!fields[fieldName]) {
258
+ fields[fieldName] = text({
259
+ validation: { isRequired: fieldAttr.required },
260
+ })
261
+ }
262
+ }
263
+ }
264
+
265
+ // Create list config with default access control
266
+ return list({
267
+ fields,
268
+ access: getDefaultAccess(tableName),
269
+ })
270
+ }
271
+
272
+ /**
273
+ * Convert all Better Auth tables to OpenSaaS list configs
274
+ * This is called by withAuth() to generate lists from Better Auth + plugins
275
+ */
276
+ export function convertBetterAuthSchema(
277
+ tables: Record<string, BetterAuthTableSchema>,
278
+ ): Record<string, ListConfig> {
279
+ const lists: Record<string, ListConfig> = {}
280
+
281
+ for (const [tableName, tableSchema] of Object.entries(tables)) {
282
+ // Convert table name to PascalCase for OpenSaaS list key
283
+ const listKey = tableSchema.modelName || toPascalCase(tableName)
284
+ lists[listKey] = convertTableToList(tableName, tableSchema)
285
+ }
286
+
287
+ return lists
288
+ }
289
+
290
+ /**
291
+ * Convert table name to PascalCase
292
+ * e.g., "oauth_application" -> "OauthApplication"
293
+ */
294
+ function toPascalCase(str: string): string {
295
+ return str
296
+ .split(/[_-]/)
297
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
298
+ .join('')
299
+ }
@@ -0,0 +1,270 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { normalizeAuthConfig, authConfig, withAuth } from '../src/config/index.js'
3
+ import { config, list } from '@opensaas/stack-core'
4
+ import { text } from '@opensaas/stack-core/fields'
5
+ import type { AuthConfig } from '../src/config/types.js'
6
+
7
+ describe('normalizeAuthConfig', () => {
8
+ it('should apply default values for disabled features', () => {
9
+ const result = normalizeAuthConfig({})
10
+
11
+ expect(result.emailAndPassword.enabled).toBe(false)
12
+ expect(result.emailAndPassword.minPasswordLength).toBe(8)
13
+ expect(result.emailVerification.enabled).toBe(false)
14
+ expect(result.passwordReset.enabled).toBe(false)
15
+ expect(result.session.expiresIn).toBe(604800) // 7 days
16
+ expect(result.sessionFields).toEqual(['userId', 'email', 'name'])
17
+ expect(result.socialProviders).toEqual({})
18
+ expect(result.betterAuthPlugins).toEqual([])
19
+ })
20
+
21
+ it('should normalize email and password config', () => {
22
+ const result = normalizeAuthConfig({
23
+ emailAndPassword: {
24
+ enabled: true,
25
+ minPasswordLength: 12,
26
+ requireConfirmation: false,
27
+ },
28
+ })
29
+
30
+ expect(result.emailAndPassword.enabled).toBe(true)
31
+ expect(result.emailAndPassword.minPasswordLength).toBe(12)
32
+ expect(result.emailAndPassword.requireConfirmation).toBe(false)
33
+ })
34
+
35
+ it('should apply defaults for partial email and password config', () => {
36
+ const result = normalizeAuthConfig({
37
+ emailAndPassword: { enabled: true },
38
+ })
39
+
40
+ expect(result.emailAndPassword.enabled).toBe(true)
41
+ expect(result.emailAndPassword.minPasswordLength).toBe(8)
42
+ expect(result.emailAndPassword.requireConfirmation).toBe(true)
43
+ })
44
+
45
+ it('should normalize email verification config', () => {
46
+ const result = normalizeAuthConfig({
47
+ emailVerification: {
48
+ enabled: true,
49
+ sendOnSignUp: false,
50
+ tokenExpiration: 3600,
51
+ },
52
+ })
53
+
54
+ expect(result.emailVerification.enabled).toBe(true)
55
+ expect(result.emailVerification.sendOnSignUp).toBe(false)
56
+ expect(result.emailVerification.tokenExpiration).toBe(3600)
57
+ })
58
+
59
+ it('should normalize password reset config', () => {
60
+ const result = normalizeAuthConfig({
61
+ passwordReset: {
62
+ enabled: true,
63
+ tokenExpiration: 7200,
64
+ },
65
+ })
66
+
67
+ expect(result.passwordReset.enabled).toBe(true)
68
+ expect(result.passwordReset.tokenExpiration).toBe(7200)
69
+ })
70
+
71
+ it('should normalize session config', () => {
72
+ const result = normalizeAuthConfig({
73
+ session: {
74
+ expiresIn: 86400, // 1 day
75
+ updateAge: false,
76
+ },
77
+ })
78
+
79
+ expect(result.session.expiresIn).toBe(86400)
80
+ expect(result.session.updateAge).toBe(false)
81
+ })
82
+
83
+ it('should normalize custom session fields', () => {
84
+ const result = normalizeAuthConfig({
85
+ sessionFields: ['userId', 'email', 'role'],
86
+ })
87
+
88
+ expect(result.sessionFields).toEqual(['userId', 'email', 'role'])
89
+ })
90
+
91
+ it('should include social providers', () => {
92
+ const result = normalizeAuthConfig({
93
+ socialProviders: {
94
+ github: {
95
+ clientId: 'github-id',
96
+ clientSecret: 'github-secret',
97
+ },
98
+ },
99
+ })
100
+
101
+ expect(result.socialProviders).toEqual({
102
+ github: {
103
+ clientId: 'github-id',
104
+ clientSecret: 'github-secret',
105
+ },
106
+ })
107
+ })
108
+
109
+ it('should include extendUserList config', () => {
110
+ const result = normalizeAuthConfig({
111
+ extendUserList: {
112
+ fields: {
113
+ role: text(),
114
+ },
115
+ },
116
+ })
117
+
118
+ expect(result.extendUserList).toHaveProperty('fields')
119
+ expect(result.extendUserList.fields).toHaveProperty('role')
120
+ })
121
+
122
+ it('should include custom sendEmail function', () => {
123
+ const mockSendEmail = async () => {}
124
+
125
+ const result = normalizeAuthConfig({
126
+ sendEmail: mockSendEmail,
127
+ })
128
+
129
+ expect(result.sendEmail).toBe(mockSendEmail)
130
+ })
131
+
132
+ it('should provide default sendEmail that logs to console', () => {
133
+ const result = normalizeAuthConfig({})
134
+
135
+ expect(typeof result.sendEmail).toBe('function')
136
+ // Default sendEmail should be a function that accepts email params
137
+ expect(result.sendEmail.length).toBe(1)
138
+ })
139
+
140
+ it('should include betterAuthPlugins', () => {
141
+ const mockPlugin = { id: 'test-plugin' }
142
+
143
+ const result = normalizeAuthConfig({
144
+ betterAuthPlugins: [mockPlugin],
145
+ })
146
+
147
+ expect(result.betterAuthPlugins).toEqual([mockPlugin])
148
+ })
149
+ })
150
+
151
+ describe('authConfig', () => {
152
+ it('should return the input config unchanged', () => {
153
+ const input: AuthConfig = {
154
+ emailAndPassword: { enabled: true },
155
+ sessionFields: ['userId', 'email'],
156
+ }
157
+
158
+ const result = authConfig(input)
159
+
160
+ expect(result).toEqual(input)
161
+ })
162
+
163
+ it('should work with empty config', () => {
164
+ const result = authConfig({})
165
+
166
+ expect(result).toEqual({})
167
+ })
168
+ })
169
+
170
+ describe('withAuth', () => {
171
+ it('should merge auth lists into opensaas config', () => {
172
+ const baseConfig = config({
173
+ db: {
174
+ provider: 'sqlite',
175
+ url: 'file:./test.db',
176
+ },
177
+ lists: {
178
+ Post: list({
179
+ fields: {
180
+ title: text(),
181
+ },
182
+ }),
183
+ },
184
+ })
185
+
186
+ const result = withAuth(baseConfig, authConfig({}))
187
+
188
+ // Should have both user lists and auth lists
189
+ expect(result.lists).toHaveProperty('Post')
190
+ expect(result.lists).toHaveProperty('User')
191
+ expect(result.lists).toHaveProperty('Session')
192
+ expect(result.lists).toHaveProperty('Account')
193
+ expect(result.lists).toHaveProperty('Verification')
194
+ })
195
+
196
+ it('should preserve database config', () => {
197
+ const baseConfig = config({
198
+ db: {
199
+ provider: 'postgresql',
200
+ url: 'postgresql://test',
201
+ },
202
+ lists: {},
203
+ })
204
+
205
+ const result = withAuth(baseConfig, authConfig({}))
206
+
207
+ expect(result.db.provider).toBe('postgresql')
208
+ expect(result.db.url).toBe('postgresql://test')
209
+ })
210
+
211
+ it('should attach normalized auth config internally', () => {
212
+ const baseConfig = config({
213
+ db: { provider: 'sqlite', url: 'file:./test.db' },
214
+ lists: {},
215
+ })
216
+
217
+ const result = withAuth(
218
+ baseConfig,
219
+ authConfig({
220
+ emailAndPassword: { enabled: true },
221
+ }),
222
+ ) as typeof baseConfig & { __authConfig?: unknown }
223
+
224
+ expect(result.__authConfig).toBeDefined()
225
+ expect(
226
+ (result.__authConfig as { emailAndPassword: { enabled: boolean } }).emailAndPassword.enabled,
227
+ ).toBe(true)
228
+ })
229
+
230
+ it('should handle empty lists in base config', () => {
231
+ const baseConfig = config({
232
+ db: { provider: 'sqlite', url: 'file:./test.db' },
233
+ lists: {},
234
+ })
235
+
236
+ const result = withAuth(baseConfig, authConfig({}))
237
+
238
+ expect(result.lists).toHaveProperty('User')
239
+ expect(result.lists).toHaveProperty('Session')
240
+ expect(result.lists).toHaveProperty('Account')
241
+ expect(result.lists).toHaveProperty('Verification')
242
+ })
243
+
244
+ it('should extend User list with custom fields', () => {
245
+ const baseConfig = config({
246
+ db: { provider: 'sqlite', url: 'file:./test.db' },
247
+ lists: {},
248
+ })
249
+
250
+ const result = withAuth(
251
+ baseConfig,
252
+ authConfig({
253
+ extendUserList: {
254
+ fields: {
255
+ role: text(),
256
+ company: text(),
257
+ },
258
+ },
259
+ }),
260
+ )
261
+
262
+ const userList = result.lists.User
263
+ expect(userList).toBeDefined()
264
+ expect(userList.fields).toHaveProperty('role')
265
+ expect(userList.fields).toHaveProperty('company')
266
+ // Should also have base auth fields
267
+ expect(userList.fields).toHaveProperty('email')
268
+ expect(userList.fields).toHaveProperty('name')
269
+ })
270
+ })