@opensaas/stack-auth 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +92 -0
- package/CLAUDE.md +98 -0
- package/README.md +33 -0
- package/dist/config/adopt-better-auth-tables.d.ts +107 -0
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -0
- package/dist/config/adopt-better-auth-tables.js +70 -0
- package/dist/config/adopt-better-auth-tables.js.map +1 -0
- package/dist/config/derive-auth-lists.d.ts +50 -0
- package/dist/config/derive-auth-lists.d.ts.map +1 -0
- package/dist/config/derive-auth-lists.js +274 -0
- package/dist/config/derive-auth-lists.js.map +1 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +43 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +52 -9
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +130 -3
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/lists/index.d.ts +17 -11
- package/dist/lists/index.d.ts.map +1 -1
- package/dist/lists/index.js +34 -208
- package/dist/lists/index.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +28 -7
- package/dist/server/index.js.map +1 -1
- package/package.json +2 -2
- package/src/config/adopt-better-auth-tables.ts +146 -0
- package/src/config/derive-auth-lists.ts +323 -0
- package/src/config/index.ts +58 -0
- package/src/config/plugin.ts +66 -9
- package/src/config/types.ts +146 -3
- package/src/index.ts +13 -0
- package/src/lists/index.ts +42 -202
- package/src/server/index.ts +31 -9
- package/tests/adopt-better-auth-tables.test.ts +183 -0
- package/tests/derive-auth-lists.test.ts +232 -0
- package/tests/plugin-derived-keys.test.ts +138 -0
- package/tests/plugin-schema-placement.test.ts +121 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { config, list } from '@opensaas/stack-core'
|
|
3
|
+
import { text } from '@opensaas/stack-core/fields'
|
|
4
|
+
import type { OpenSaasConfig } from '@opensaas/stack-core'
|
|
5
|
+
import type { Plugin } from '@opensaas/stack-core/extend'
|
|
6
|
+
import { authPlugin } from '../src/config/plugin.js'
|
|
7
|
+
import { adoptBetterAuthTables } from '../src/config/adopt-better-auth-tables.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run a config through plugin `init` (via `config()`) and then the auth
|
|
11
|
+
* plugin's `beforeGenerate` hook — mirroring the CLI generate pipeline — to
|
|
12
|
+
* observe the final config the generator would consume. Mirrors the helper in
|
|
13
|
+
* `plugin-schema-placement.test.ts`.
|
|
14
|
+
*/
|
|
15
|
+
async function generationConfig(userConfig: OpenSaasConfig): Promise<OpenSaasConfig> {
|
|
16
|
+
const resolved = await config(userConfig)
|
|
17
|
+
let current = resolved
|
|
18
|
+
const plugins: Plugin[] = resolved.plugins ?? []
|
|
19
|
+
for (const plugin of plugins) {
|
|
20
|
+
if (plugin.beforeGenerate) {
|
|
21
|
+
current = await plugin.beforeGenerate(current)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return current
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('adoptBetterAuthTables - recipe defaults', () => {
|
|
28
|
+
it('produces the standard separate-schema better-auth defaults', () => {
|
|
29
|
+
const fragment = adoptBetterAuthTables()
|
|
30
|
+
|
|
31
|
+
expect(fragment).toEqual({
|
|
32
|
+
schema: 'auth',
|
|
33
|
+
user: { modelName: 'AuthUser' },
|
|
34
|
+
session: { modelName: 'AuthSession' },
|
|
35
|
+
account: { modelName: 'AuthAccount' },
|
|
36
|
+
verification: { modelName: 'AuthVerification' },
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('honours a custom schema and model-name prefix', () => {
|
|
41
|
+
const fragment = adoptBetterAuthTables({ schema: 'identity', modelNamePrefix: 'BA' })
|
|
42
|
+
|
|
43
|
+
expect(fragment).toEqual({
|
|
44
|
+
schema: 'identity',
|
|
45
|
+
user: { modelName: 'BAUser' },
|
|
46
|
+
session: { modelName: 'BASession' },
|
|
47
|
+
account: { modelName: 'BAAccount' },
|
|
48
|
+
verification: { modelName: 'BAVerification' },
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('merges per-model field column maps when provided', () => {
|
|
53
|
+
const fragment = adoptBetterAuthTables({
|
|
54
|
+
fields: {
|
|
55
|
+
user: { name: 'full_name', emailVerified: 'is_verified' },
|
|
56
|
+
session: { userId: 'user_id' },
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
expect(fragment.user).toEqual({
|
|
61
|
+
modelName: 'AuthUser',
|
|
62
|
+
fields: { name: 'full_name', emailVerified: 'is_verified' },
|
|
63
|
+
})
|
|
64
|
+
expect(fragment.session).toEqual({
|
|
65
|
+
modelName: 'AuthSession',
|
|
66
|
+
fields: { userId: 'user_id' },
|
|
67
|
+
})
|
|
68
|
+
// Models without a field map carry no `fields` key (no empty object)
|
|
69
|
+
expect(fragment.account).toEqual({ modelName: 'AuthAccount' })
|
|
70
|
+
expect(fragment.verification).toEqual({ modelName: 'AuthVerification' })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('omits the schema when explicitly set to public', () => {
|
|
74
|
+
const fragment = adoptBetterAuthTables({ schema: 'public' })
|
|
75
|
+
expect(fragment.schema).toBe('public')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('adoptBetterAuthTables - composes with authPlugin', () => {
|
|
80
|
+
it('spreads into authPlugin alongside the rest of the auth config', async () => {
|
|
81
|
+
const result = await config({
|
|
82
|
+
db: { provider: 'postgresql' },
|
|
83
|
+
plugins: [
|
|
84
|
+
authPlugin({
|
|
85
|
+
...adoptBetterAuthTables(),
|
|
86
|
+
emailAndPassword: { enabled: true },
|
|
87
|
+
sessionFields: ['userId', 'email', 'name'],
|
|
88
|
+
}),
|
|
89
|
+
],
|
|
90
|
+
lists: {},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// The recipe's derived Auth lists are present...
|
|
94
|
+
expect(result.lists).toHaveProperty('AuthUser')
|
|
95
|
+
expect(result.lists).toHaveProperty('AuthSession')
|
|
96
|
+
expect(result.lists).toHaveProperty('AuthAccount')
|
|
97
|
+
expect(result.lists).toHaveProperty('AuthVerification')
|
|
98
|
+
// ...keyed off the recipe's model names, not the default `User`/`Session`.
|
|
99
|
+
expect(result.lists).not.toHaveProperty('User')
|
|
100
|
+
expect(result.lists).not.toHaveProperty('Session')
|
|
101
|
+
|
|
102
|
+
// The rest of the auth config still applies (stored for runtime).
|
|
103
|
+
const authData = result._pluginData?.auth as { emailAndPassword?: { enabled?: boolean } }
|
|
104
|
+
expect(authData?.emailAndPassword?.enabled).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('adoptBetterAuthTables - clean-diff adoption (Auth lists ≠ app User)', () => {
|
|
109
|
+
it('lands every Auth list in the auth schema with @@map + @@schema and leaves the app User untouched', async () => {
|
|
110
|
+
const result = await generationConfig({
|
|
111
|
+
db: { provider: 'postgresql' },
|
|
112
|
+
plugins: [
|
|
113
|
+
authPlugin({
|
|
114
|
+
...adoptBetterAuthTables(),
|
|
115
|
+
emailAndPassword: { enabled: true },
|
|
116
|
+
}),
|
|
117
|
+
],
|
|
118
|
+
// The migrating app keeps its own domain User (public.User), keyed by
|
|
119
|
+
// its own subjectId — a DIFFERENT model from the better-auth user.
|
|
120
|
+
lists: {
|
|
121
|
+
User: list({
|
|
122
|
+
fields: {
|
|
123
|
+
subjectId: text({ validation: { isRequired: true } }),
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Each Auth list is pinned to its live table name (@@map) and the `auth`
|
|
130
|
+
// schema (@@schema), with auto-timestamps preserved (ADR-0004) — exactly the
|
|
131
|
+
// shape that diffs CLEAN against a live separate-schema better-auth install.
|
|
132
|
+
expect(result.lists.AuthUser.db).toEqual({ timestamps: true, map: 'AuthUser', schema: 'auth' })
|
|
133
|
+
expect(result.lists.AuthSession.db).toEqual({
|
|
134
|
+
timestamps: true,
|
|
135
|
+
map: 'AuthSession',
|
|
136
|
+
schema: 'auth',
|
|
137
|
+
})
|
|
138
|
+
expect(result.lists.AuthAccount.db).toEqual({
|
|
139
|
+
timestamps: true,
|
|
140
|
+
map: 'AuthAccount',
|
|
141
|
+
schema: 'auth',
|
|
142
|
+
})
|
|
143
|
+
expect(result.lists.AuthVerification.db).toEqual({
|
|
144
|
+
timestamps: true,
|
|
145
|
+
map: 'AuthVerification',
|
|
146
|
+
schema: 'auth',
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// The app's own domain User is preserved: its field shape is intact, NOT
|
|
150
|
+
// merged with auth fields, and it stays in `public` (not the auth schema).
|
|
151
|
+
const appUser = result.lists.User
|
|
152
|
+
expect(appUser.fields).toHaveProperty('subjectId')
|
|
153
|
+
expect(appUser.fields).not.toHaveProperty('email')
|
|
154
|
+
expect(appUser.fields).not.toHaveProperty('emailVerified')
|
|
155
|
+
expect(appUser.fields).not.toHaveProperty('sessions')
|
|
156
|
+
expect(appUser.db?.schema).toBe('public')
|
|
157
|
+
|
|
158
|
+
// The datasource lists both schemas so the multi-schema Prisma schema is valid.
|
|
159
|
+
expect(result.db.schemas).toEqual(['public', 'auth'])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('carries field column maps through to the derived Auth lists for renamed columns', async () => {
|
|
163
|
+
const result = await config({
|
|
164
|
+
db: { provider: 'postgresql' },
|
|
165
|
+
plugins: [
|
|
166
|
+
authPlugin({
|
|
167
|
+
...adoptBetterAuthTables({
|
|
168
|
+
fields: {
|
|
169
|
+
user: { name: 'full_name' },
|
|
170
|
+
session: { userId: 'user_id' },
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
}),
|
|
174
|
+
],
|
|
175
|
+
lists: {},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// Renamed columns flow through to the derived field-level @map / FK @map so
|
|
179
|
+
// the lists match the live columns.
|
|
180
|
+
expect(result.lists.AuthUser.fields.name.db?.map).toBe('full_name')
|
|
181
|
+
expect(result.lists.AuthSession.fields.user.db?.foreignKey).toEqual({ map: 'user_id' })
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { deriveAuthLists } from '../src/config/derive-auth-lists.js'
|
|
3
|
+
import type { NormalizedAuthModels } from '../src/config/types.js'
|
|
4
|
+
|
|
5
|
+
const defaultModels: NormalizedAuthModels = {
|
|
6
|
+
user: { modelName: 'User', fields: {} },
|
|
7
|
+
session: { modelName: 'Session', fields: {} },
|
|
8
|
+
account: { modelName: 'Account', fields: {} },
|
|
9
|
+
verification: { modelName: 'Verification', fields: {} },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('deriveAuthLists - default behaviour (no overrides)', () => {
|
|
13
|
+
it('keeps the historical User/Session/Account/Verification keys', () => {
|
|
14
|
+
const { keys, lists } = deriveAuthLists(defaultModels)
|
|
15
|
+
|
|
16
|
+
expect(keys).toEqual({
|
|
17
|
+
user: 'User',
|
|
18
|
+
session: 'Session',
|
|
19
|
+
account: 'Account',
|
|
20
|
+
verification: 'Verification',
|
|
21
|
+
})
|
|
22
|
+
expect(Object.keys(lists).sort()).toEqual(['Account', 'Session', 'User', 'Verification'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('keeps the original User field shape', () => {
|
|
26
|
+
const { lists } = deriveAuthLists(defaultModels)
|
|
27
|
+
const user = lists.User
|
|
28
|
+
|
|
29
|
+
expect(user.fields).toHaveProperty('name')
|
|
30
|
+
expect(user.fields).toHaveProperty('email')
|
|
31
|
+
expect(user.fields).toHaveProperty('emailVerified')
|
|
32
|
+
expect(user.fields).toHaveProperty('image')
|
|
33
|
+
expect(user.fields).toHaveProperty('sessions')
|
|
34
|
+
expect(user.fields).toHaveProperty('accounts')
|
|
35
|
+
expect(user.fields.email.isIndexed).toBe('unique')
|
|
36
|
+
expect(user.fields.name.validation?.isRequired).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('wires relationship refs to the default keys', () => {
|
|
40
|
+
const { lists } = deriveAuthLists(defaultModels)
|
|
41
|
+
expect(lists.Session.fields.user.ref).toBe('User.sessions')
|
|
42
|
+
expect(lists.Account.fields.user.ref).toBe('User.accounts')
|
|
43
|
+
expect(lists.User.fields.sessions.ref).toBe('Session.user')
|
|
44
|
+
expect(lists.User.fields.accounts.ref).toBe('Account.user')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('emits no table @@map and no scalar @map for default keys', () => {
|
|
48
|
+
const { lists } = deriveAuthLists(defaultModels)
|
|
49
|
+
|
|
50
|
+
expect(lists.User.db?.map).toBeUndefined()
|
|
51
|
+
expect(lists.Session.db?.map).toBeUndefined()
|
|
52
|
+
expect(lists.Account.db?.map).toBeUndefined()
|
|
53
|
+
expect(lists.Verification.db?.map).toBeUndefined()
|
|
54
|
+
|
|
55
|
+
expect(lists.User.fields.name.db?.map).toBeUndefined()
|
|
56
|
+
expect(lists.Session.fields.token.db?.map).toBeUndefined()
|
|
57
|
+
// FK column not overridden -> no foreignKey map on the relationship
|
|
58
|
+
expect(lists.Session.fields.user.db).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('opts every auth list into auto-timestamps', () => {
|
|
62
|
+
// Auto-timestamps are OFF by default (ADR-0004), but better-auth's adapter
|
|
63
|
+
// writes createdAt/updatedAt on every auth row and the schema converter
|
|
64
|
+
// returns null for those columns assuming the generator injects them. Each
|
|
65
|
+
// derived auth list must therefore re-enable them via db.timestamps.
|
|
66
|
+
const { lists } = deriveAuthLists(defaultModels)
|
|
67
|
+
|
|
68
|
+
expect(lists.User.db?.timestamps).toBe(true)
|
|
69
|
+
expect(lists.Session.db?.timestamps).toBe(true)
|
|
70
|
+
expect(lists.Account.db?.timestamps).toBe(true)
|
|
71
|
+
expect(lists.Verification.db?.timestamps).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('deriveAuthLists - custom modelName overrides', () => {
|
|
76
|
+
const customModels: NormalizedAuthModels = {
|
|
77
|
+
user: { modelName: 'AuthUser', fields: {} },
|
|
78
|
+
session: { modelName: 'AuthSession', fields: {} },
|
|
79
|
+
account: { modelName: 'AuthAccount', fields: {} },
|
|
80
|
+
verification: { modelName: 'AuthVerification', fields: {} },
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it('derives list keys from modelName', () => {
|
|
84
|
+
const { keys, lists } = deriveAuthLists(customModels)
|
|
85
|
+
|
|
86
|
+
expect(keys).toEqual({
|
|
87
|
+
user: 'AuthUser',
|
|
88
|
+
session: 'AuthSession',
|
|
89
|
+
account: 'AuthAccount',
|
|
90
|
+
verification: 'AuthVerification',
|
|
91
|
+
})
|
|
92
|
+
expect(Object.keys(lists).sort()).toEqual([
|
|
93
|
+
'AuthAccount',
|
|
94
|
+
'AuthSession',
|
|
95
|
+
'AuthUser',
|
|
96
|
+
'AuthVerification',
|
|
97
|
+
])
|
|
98
|
+
// The app's own `User` key must NOT be produced by the plugin
|
|
99
|
+
expect(lists).not.toHaveProperty('User')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('wires relationship refs to the derived keys', () => {
|
|
103
|
+
const { lists } = deriveAuthLists(customModels)
|
|
104
|
+
expect(lists.AuthSession.fields.user.ref).toBe('AuthUser.sessions')
|
|
105
|
+
expect(lists.AuthAccount.fields.user.ref).toBe('AuthUser.accounts')
|
|
106
|
+
expect(lists.AuthUser.fields.sessions.ref).toBe('AuthSession.user')
|
|
107
|
+
expect(lists.AuthUser.fields.accounts.ref).toBe('AuthAccount.user')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('pins each renamed list to a table @@map equal to the model name', () => {
|
|
111
|
+
const { lists } = deriveAuthLists(customModels)
|
|
112
|
+
|
|
113
|
+
expect(lists.AuthUser.db?.map).toBe('AuthUser')
|
|
114
|
+
expect(lists.AuthSession.db?.map).toBe('AuthSession')
|
|
115
|
+
expect(lists.AuthAccount.db?.map).toBe('AuthAccount')
|
|
116
|
+
expect(lists.AuthVerification.db?.map).toBe('AuthVerification')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('keeps auto-timestamps enabled alongside the table @@map', () => {
|
|
120
|
+
const { lists } = deriveAuthLists(customModels)
|
|
121
|
+
|
|
122
|
+
expect(lists.AuthUser.db?.timestamps).toBe(true)
|
|
123
|
+
expect(lists.AuthSession.db?.timestamps).toBe(true)
|
|
124
|
+
expect(lists.AuthAccount.db?.timestamps).toBe(true)
|
|
125
|
+
expect(lists.AuthVerification.db?.timestamps).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('deriveAuthLists - custom field column maps', () => {
|
|
130
|
+
const models: NormalizedAuthModels = {
|
|
131
|
+
user: { modelName: 'AuthUser', fields: { name: 'full_name', emailVerified: 'is_verified' } },
|
|
132
|
+
session: { modelName: 'AuthSession', fields: { token: 'session_token', userId: 'user_id' } },
|
|
133
|
+
account: { modelName: 'AuthAccount', fields: { userId: 'user_id' } },
|
|
134
|
+
verification: { modelName: 'AuthVerification', fields: {} },
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it('applies @map column overrides to scalar fields', () => {
|
|
138
|
+
const { lists } = deriveAuthLists(models)
|
|
139
|
+
|
|
140
|
+
expect(lists.AuthUser.fields.name.db?.map).toBe('full_name')
|
|
141
|
+
expect(lists.AuthUser.fields.emailVerified.db?.map).toBe('is_verified')
|
|
142
|
+
expect(lists.AuthSession.fields.token.db?.map).toBe('session_token')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('applies the userId column override to the relationship foreign key', () => {
|
|
146
|
+
const { lists } = deriveAuthLists(models)
|
|
147
|
+
|
|
148
|
+
expect(lists.AuthSession.fields.user.db?.foreignKey).toEqual({ map: 'user_id' })
|
|
149
|
+
expect(lists.AuthAccount.fields.user.db?.foreignKey).toEqual({ map: 'user_id' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('only maps fields that have an override, leaving others unmapped', () => {
|
|
153
|
+
const { lists } = deriveAuthLists(models)
|
|
154
|
+
// name is mapped, email is not
|
|
155
|
+
expect(lists.AuthUser.fields.name.db?.map).toBe('full_name')
|
|
156
|
+
expect(lists.AuthUser.fields.email.db?.map).toBeUndefined()
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('deriveAuthLists - schema placement', () => {
|
|
161
|
+
it('places all lists in the configured schema via db.schema', () => {
|
|
162
|
+
const models: NormalizedAuthModels = {
|
|
163
|
+
user: { modelName: 'AuthUser', fields: {}, schema: 'auth' },
|
|
164
|
+
session: { modelName: 'AuthSession', fields: {}, schema: 'auth' },
|
|
165
|
+
account: { modelName: 'AuthAccount', fields: {}, schema: 'auth' },
|
|
166
|
+
verification: { modelName: 'AuthVerification', fields: {}, schema: 'auth' },
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { lists } = deriveAuthLists(models)
|
|
170
|
+
|
|
171
|
+
expect(lists.AuthUser.db?.schema).toBe('auth')
|
|
172
|
+
expect(lists.AuthSession.db?.schema).toBe('auth')
|
|
173
|
+
expect(lists.AuthAccount.db?.schema).toBe('auth')
|
|
174
|
+
expect(lists.AuthVerification.db?.schema).toBe('auth')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('carries both @@map and @@schema for renamed + relocated lists', () => {
|
|
178
|
+
const models: NormalizedAuthModels = {
|
|
179
|
+
user: { modelName: 'AuthUser', fields: {}, schema: 'auth' },
|
|
180
|
+
session: { modelName: 'AuthSession', fields: {}, schema: 'auth' },
|
|
181
|
+
account: { modelName: 'AuthAccount', fields: {}, schema: 'auth' },
|
|
182
|
+
verification: { modelName: 'AuthVerification', fields: {}, schema: 'auth' },
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { lists } = deriveAuthLists(models)
|
|
186
|
+
|
|
187
|
+
// Auth lists always opt into auto-timestamps (ADR-0004) alongside the
|
|
188
|
+
// table @@map and @@schema placement.
|
|
189
|
+
expect(lists.AuthUser.db).toEqual({ timestamps: true, map: 'AuthUser', schema: 'auth' })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('honours a per-model schema override alongside a different default schema', () => {
|
|
193
|
+
const models: NormalizedAuthModels = {
|
|
194
|
+
user: { modelName: 'AuthUser', fields: {}, schema: 'auth' },
|
|
195
|
+
session: { modelName: 'AuthSession', fields: {}, schema: 'auth' },
|
|
196
|
+
account: { modelName: 'AuthAccount', fields: {}, schema: 'auth' },
|
|
197
|
+
// One list targets a different schema than the rest
|
|
198
|
+
verification: { modelName: 'AuthVerification', fields: {}, schema: 'auth_internal' },
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { lists } = deriveAuthLists(models)
|
|
202
|
+
|
|
203
|
+
expect(lists.AuthUser.db?.schema).toBe('auth')
|
|
204
|
+
expect(lists.AuthVerification.db?.schema).toBe('auth_internal')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('emits no @@schema for the default (no-schema) configuration', () => {
|
|
208
|
+
const { lists } = deriveAuthLists(defaultModels)
|
|
209
|
+
|
|
210
|
+
// Auth lists still opt into auto-timestamps (ADR-0004); the greenfield
|
|
211
|
+
// default just carries no schema/map placement.
|
|
212
|
+
expect(lists.User.db).toEqual({ timestamps: true })
|
|
213
|
+
expect(lists.User.db?.schema).toBeUndefined()
|
|
214
|
+
expect(lists.Session.db?.schema).toBeUndefined()
|
|
215
|
+
expect(lists.Account.db?.schema).toBeUndefined()
|
|
216
|
+
expect(lists.Verification.db?.schema).toBeUndefined()
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('deriveAuthLists - extendUserList', () => {
|
|
221
|
+
it('adds custom fields to the derived user list', () => {
|
|
222
|
+
const { lists } = deriveAuthLists(
|
|
223
|
+
{ ...defaultModels, user: { modelName: 'AuthUser', fields: {} } },
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal custom field config for test
|
|
225
|
+
{ fields: { role: { type: 'text' } as any } },
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
expect(lists.AuthUser.fields).toHaveProperty('role')
|
|
229
|
+
// Base fields still present
|
|
230
|
+
expect(lists.AuthUser.fields).toHaveProperty('email')
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { config, list } from '@opensaas/stack-core'
|
|
3
|
+
import { text } from '@opensaas/stack-core/fields'
|
|
4
|
+
import type { AccessContext } from '@opensaas/stack-core'
|
|
5
|
+
import { authPlugin } from '../src/config/plugin.js'
|
|
6
|
+
import type { AuthRuntimeServices } from '../src/runtime/types.js'
|
|
7
|
+
|
|
8
|
+
describe('authPlugin - add-vs-extend with derived keys', () => {
|
|
9
|
+
it("does NOT extend or overwrite an app's own User when keys are customised", async () => {
|
|
10
|
+
// The app declares its own domain `User` (a different model from the
|
|
11
|
+
// better-auth user). The plugin renames its user model to `AuthUser`.
|
|
12
|
+
const appUserHook = vi.fn()
|
|
13
|
+
const result = await config({
|
|
14
|
+
db: { provider: 'sqlite' },
|
|
15
|
+
plugins: [
|
|
16
|
+
authPlugin({
|
|
17
|
+
user: { modelName: 'AuthUser' },
|
|
18
|
+
session: { modelName: 'AuthSession' },
|
|
19
|
+
account: { modelName: 'AuthAccount' },
|
|
20
|
+
verification: { modelName: 'AuthVerification' },
|
|
21
|
+
}),
|
|
22
|
+
],
|
|
23
|
+
lists: {
|
|
24
|
+
User: list({
|
|
25
|
+
fields: {
|
|
26
|
+
subjectId: text({ validation: { isRequired: true } }),
|
|
27
|
+
},
|
|
28
|
+
hooks: { beforeOperation: appUserHook },
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// The plugin adds its own AuthUser/AuthSession/... lists
|
|
34
|
+
expect(result.lists).toHaveProperty('AuthUser')
|
|
35
|
+
expect(result.lists).toHaveProperty('AuthSession')
|
|
36
|
+
expect(result.lists).toHaveProperty('AuthAccount')
|
|
37
|
+
expect(result.lists).toHaveProperty('AuthVerification')
|
|
38
|
+
|
|
39
|
+
// The app's own `User` is left completely untouched: its field shape is
|
|
40
|
+
// preserved and NOT merged with auth fields (no email/name/sessions).
|
|
41
|
+
const appUser = result.lists.User
|
|
42
|
+
expect(appUser.fields).toHaveProperty('subjectId')
|
|
43
|
+
expect(appUser.fields).not.toHaveProperty('email')
|
|
44
|
+
expect(appUser.fields).not.toHaveProperty('emailVerified')
|
|
45
|
+
expect(appUser.fields).not.toHaveProperty('sessions')
|
|
46
|
+
// Its hooks are preserved (not replaced by the auth user's hooks)
|
|
47
|
+
expect(appUser.hooks?.beforeOperation).toBe(appUserHook)
|
|
48
|
+
|
|
49
|
+
// And the auth user list (AuthUser) is the one carrying auth fields
|
|
50
|
+
expect(result.lists.AuthUser.fields).toHaveProperty('email')
|
|
51
|
+
expect(result.lists.AuthUser.fields).toHaveProperty('sessions')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('still merges auth fields into an existing list that shares the default key', async () => {
|
|
55
|
+
// Default keys: the plugin's user key is `User`, so an existing `User`
|
|
56
|
+
// is intentionally extended with auth fields (the historical behaviour).
|
|
57
|
+
const result = await config({
|
|
58
|
+
db: { provider: 'sqlite' },
|
|
59
|
+
plugins: [authPlugin({})],
|
|
60
|
+
lists: {
|
|
61
|
+
User: list({
|
|
62
|
+
fields: {
|
|
63
|
+
bio: text(),
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const user = result.lists.User
|
|
70
|
+
expect(user.fields).toHaveProperty('bio') // app field preserved
|
|
71
|
+
expect(user.fields).toHaveProperty('email') // auth field merged in
|
|
72
|
+
expect(user.fields).toHaveProperty('sessions')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('authPlugin - runtime user-key resolution', () => {
|
|
77
|
+
/**
|
|
78
|
+
* Build a minimal AccessContext whose `db` records which model key was
|
|
79
|
+
* accessed, so we can assert the runtime resolves the configured user model.
|
|
80
|
+
*/
|
|
81
|
+
function makeFakeContext(session: { userId?: string } | null) {
|
|
82
|
+
const accessedKeys: string[] = []
|
|
83
|
+
const db = new Proxy(
|
|
84
|
+
{},
|
|
85
|
+
{
|
|
86
|
+
get(_target, key: string) {
|
|
87
|
+
accessedKeys.push(key)
|
|
88
|
+
return {
|
|
89
|
+
findUnique: async ({ where }: { where: { id: string } }) => ({
|
|
90
|
+
id: where.id,
|
|
91
|
+
__model: key,
|
|
92
|
+
}),
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
const context = { session, db } as unknown as AccessContext
|
|
98
|
+
return { context, accessedKeys }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it('getUser uses the default `user` db key when no modelName override', () => {
|
|
102
|
+
const plugin = authPlugin({})
|
|
103
|
+
const { context, accessedKeys } = makeFakeContext({ userId: 'u1' })
|
|
104
|
+
const services = plugin.runtime?.(context) as AuthRuntimeServices
|
|
105
|
+
|
|
106
|
+
void services.getUser('u1')
|
|
107
|
+
expect(accessedKeys).toContain('user')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('getUser uses the configured user model db key (AuthUser -> authUser)', async () => {
|
|
111
|
+
const plugin = authPlugin({ user: { modelName: 'AuthUser' } })
|
|
112
|
+
const { context, accessedKeys } = makeFakeContext({ userId: 'u1' })
|
|
113
|
+
const services = plugin.runtime?.(context) as AuthRuntimeServices
|
|
114
|
+
|
|
115
|
+
const user = (await services.getUser('u1')) as { __model: string }
|
|
116
|
+
expect(accessedKeys).toContain('authUser')
|
|
117
|
+
expect(accessedKeys).not.toContain('user')
|
|
118
|
+
expect(user.__model).toBe('authUser')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('getCurrentUser uses the configured user model db key', async () => {
|
|
122
|
+
const plugin = authPlugin({ user: { modelName: 'AuthUser' } })
|
|
123
|
+
const { context, accessedKeys } = makeFakeContext({ userId: 'u1' })
|
|
124
|
+
const services = plugin.runtime?.(context) as AuthRuntimeServices
|
|
125
|
+
|
|
126
|
+
const user = (await services.getCurrentUser()) as { __model: string }
|
|
127
|
+
expect(accessedKeys).toContain('authUser')
|
|
128
|
+
expect(user.__model).toBe('authUser')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('getCurrentUser returns null when there is no session', async () => {
|
|
132
|
+
const plugin = authPlugin({ user: { modelName: 'AuthUser' } })
|
|
133
|
+
const { context } = makeFakeContext(null)
|
|
134
|
+
const services = plugin.runtime?.(context) as AuthRuntimeServices
|
|
135
|
+
|
|
136
|
+
expect(await services.getCurrentUser()).toBeNull()
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { config, list } from '@opensaas/stack-core'
|
|
3
|
+
import { text } from '@opensaas/stack-core/fields'
|
|
4
|
+
import type { OpenSaasConfig } from '@opensaas/stack-core'
|
|
5
|
+
import type { Plugin } from '@opensaas/stack-core/extend'
|
|
6
|
+
import { authPlugin } from '../src/config/plugin.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run a config through plugin `init` (via `config()`) and then the auth
|
|
10
|
+
* plugin's `beforeGenerate` hook — mirroring the CLI generate pipeline — to
|
|
11
|
+
* observe the final config the generator would consume.
|
|
12
|
+
*/
|
|
13
|
+
async function generationConfig(userConfig: OpenSaasConfig): Promise<OpenSaasConfig> {
|
|
14
|
+
const resolved = await config(userConfig)
|
|
15
|
+
let current = resolved
|
|
16
|
+
const plugins: Plugin[] = resolved.plugins ?? []
|
|
17
|
+
for (const plugin of plugins) {
|
|
18
|
+
if (plugin.beforeGenerate) {
|
|
19
|
+
current = await plugin.beforeGenerate(current)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return current
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('authPlugin - schema placement (greenfield default)', () => {
|
|
26
|
+
it('places Auth lists in no schema and leaves the datasource untouched when no schema option is set', async () => {
|
|
27
|
+
const result = await generationConfig({
|
|
28
|
+
db: { provider: 'postgresql' },
|
|
29
|
+
plugins: [authPlugin({})],
|
|
30
|
+
lists: {},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Default keys, no @@schema, no @@map. Auth lists still opt into
|
|
34
|
+
// auto-timestamps (ADR-0004), so db carries only `timestamps: true`.
|
|
35
|
+
expect(result.lists.User.db).toEqual({ timestamps: true })
|
|
36
|
+
expect(result.lists.User.db?.schema).toBeUndefined()
|
|
37
|
+
expect(result.lists.User.db?.map).toBeUndefined()
|
|
38
|
+
expect(result.lists.Session.db?.schema).toBeUndefined()
|
|
39
|
+
expect(result.lists.Account.db?.schema).toBeUndefined()
|
|
40
|
+
expect(result.lists.Verification.db?.schema).toBeUndefined()
|
|
41
|
+
|
|
42
|
+
// Datasource is NOT switched to multi-schema
|
|
43
|
+
expect(result.db.schemas).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('authPlugin - schema placement (adopt existing auth-schema install)', () => {
|
|
48
|
+
it('places all Auth lists in the configured schema with @@schema + @@map and wires the datasource', async () => {
|
|
49
|
+
const result = await generationConfig({
|
|
50
|
+
db: { provider: 'postgresql' },
|
|
51
|
+
plugins: [
|
|
52
|
+
authPlugin({
|
|
53
|
+
schema: 'auth',
|
|
54
|
+
user: { modelName: 'AuthUser' },
|
|
55
|
+
session: { modelName: 'AuthSession' },
|
|
56
|
+
account: { modelName: 'AuthAccount' },
|
|
57
|
+
verification: { modelName: 'AuthVerification' },
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
// The app keeps its own domain User in the default schema
|
|
61
|
+
lists: {
|
|
62
|
+
User: list({
|
|
63
|
+
fields: { subjectId: text({ validation: { isRequired: true } }) },
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Auth lists land in the `auth` schema, pinned to their live table names.
|
|
69
|
+
// Auto-timestamps stay enabled (ADR-0004) alongside the @@map + @@schema.
|
|
70
|
+
expect(result.lists.AuthUser.db).toEqual({ timestamps: true, map: 'AuthUser', schema: 'auth' })
|
|
71
|
+
expect(result.lists.AuthSession.db).toEqual({
|
|
72
|
+
timestamps: true,
|
|
73
|
+
map: 'AuthSession',
|
|
74
|
+
schema: 'auth',
|
|
75
|
+
})
|
|
76
|
+
expect(result.lists.AuthAccount.db).toEqual({
|
|
77
|
+
timestamps: true,
|
|
78
|
+
map: 'AuthAccount',
|
|
79
|
+
schema: 'auth',
|
|
80
|
+
})
|
|
81
|
+
expect(result.lists.AuthVerification.db).toEqual({
|
|
82
|
+
timestamps: true,
|
|
83
|
+
map: 'AuthVerification',
|
|
84
|
+
schema: 'auth',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// The app's own User is left in `public` (not extended/overwritten, not in `auth`)
|
|
88
|
+
expect(result.lists.User.fields).toHaveProperty('subjectId')
|
|
89
|
+
expect(result.lists.User.fields).not.toHaveProperty('email')
|
|
90
|
+
expect(result.lists.User.db?.schema).toBe('public')
|
|
91
|
+
|
|
92
|
+
// Datasource gains both schemas (public always included) so the multi-schema
|
|
93
|
+
// Prisma schema is valid
|
|
94
|
+
expect(result.db.schemas).toEqual(['public', 'auth'])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('honours a per-list schema override on a single Auth list', async () => {
|
|
98
|
+
const result = await generationConfig({
|
|
99
|
+
db: { provider: 'postgresql' },
|
|
100
|
+
plugins: [
|
|
101
|
+
authPlugin({
|
|
102
|
+
schema: 'auth',
|
|
103
|
+
user: { modelName: 'AuthUser' },
|
|
104
|
+
session: { modelName: 'AuthSession' },
|
|
105
|
+
account: { modelName: 'AuthAccount' },
|
|
106
|
+
// Override: verification lives in a different schema
|
|
107
|
+
verification: { modelName: 'AuthVerification', schema: 'auth_internal' },
|
|
108
|
+
}),
|
|
109
|
+
],
|
|
110
|
+
lists: {},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(result.lists.AuthUser.db?.schema).toBe('auth')
|
|
114
|
+
expect(result.lists.AuthVerification.db?.schema).toBe('auth_internal')
|
|
115
|
+
|
|
116
|
+
// All used schemas are listed on the datasource (order: public, then discovered)
|
|
117
|
+
expect(result.db.schemas).toContain('public')
|
|
118
|
+
expect(result.db.schemas).toContain('auth')
|
|
119
|
+
expect(result.db.schemas).toContain('auth_internal')
|
|
120
|
+
})
|
|
121
|
+
})
|