@opensaas/stack-auth 0.27.1 → 0.28.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 +42 -0
- package/CLAUDE.md +35 -1
- package/dist/config/derive-auth-lists.d.ts +15 -5
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +59 -98
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +1 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +31 -13
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +58 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/lists/index.d.ts +9 -4
- package/dist/lists/index.d.ts.map +1 -1
- package/dist/lists/index.js +3 -2
- package/dist/lists/index.js.map +1 -1
- package/dist/runtime/types.d.ts +7 -5
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/server/schema-converter.d.ts +34 -2
- package/dist/server/schema-converter.d.ts.map +1 -1
- package/dist/server/schema-converter.js +47 -163
- package/dist/server/schema-converter.js.map +1 -1
- package/package.json +2 -2
- package/src/config/derive-auth-lists.ts +61 -87
- package/src/config/index.ts +1 -0
- package/src/config/plugin.ts +36 -13
- package/src/config/types.ts +64 -0
- package/src/lists/index.ts +10 -4
- package/src/runtime/types.ts +7 -5
- package/src/server/schema-converter.ts +68 -158
- package/tests/config.test.ts +110 -0
- package/tests/derive-auth-lists.test.ts +54 -1
- package/tests/lists.test.ts +42 -145
- package/tests/plugin-derived-keys.test.ts +130 -18
- package/tests/schema-converter.test.ts +58 -21
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -71,20 +71,127 @@ describe('authPlugin - add-vs-extend with derived keys', () => {
|
|
|
71
71
|
expect(user.fields).toHaveProperty('email') // auth field merged in
|
|
72
72
|
expect(user.fields).toHaveProperty('sessions')
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
it("does not overwrite an app's admin-only access when the auth plugin extends the same default key (ADR-0013)", async () => {
|
|
76
|
+
// Regression test for #678: the auth plugin ships permissive defaults
|
|
77
|
+
// (query: () => true, self-only update/delete) for its own User list.
|
|
78
|
+
// Before the ADR-0013 fix, extending an app-declared `User` at the
|
|
79
|
+
// default key forwarded those permissive defaults and silently replaced
|
|
80
|
+
// the app's admin-only access. Now the plugin must not forward `access`
|
|
81
|
+
// at all on its extend path, so the app's access survives untouched.
|
|
82
|
+
const adminOnlyUpdate = vi.fn(() => false)
|
|
83
|
+
const adminOnlyQuery = vi.fn(() => false)
|
|
84
|
+
|
|
85
|
+
const result = await config({
|
|
86
|
+
db: { provider: 'sqlite' },
|
|
87
|
+
plugins: [authPlugin({})],
|
|
88
|
+
lists: {
|
|
89
|
+
User: list({
|
|
90
|
+
fields: { bio: text() },
|
|
91
|
+
access: {
|
|
92
|
+
operation: {
|
|
93
|
+
query: adminOnlyQuery,
|
|
94
|
+
update: adminOnlyUpdate,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const user = result.lists.User
|
|
102
|
+
expect(user.fields).toHaveProperty('email') // auth fields still merged in
|
|
103
|
+
// The app's admin-only access is intact — not replaced by the plugin's
|
|
104
|
+
// permissive `query: () => true` / self-only `update` defaults.
|
|
105
|
+
expect(user.access?.operation?.query).toBe(adminOnlyQuery)
|
|
106
|
+
expect(user.access?.operation?.update).toBe(adminOnlyUpdate)
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- access control parameters are runtime values
|
|
108
|
+
expect(user.access?.operation?.query?.({} as any)).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('routes a better-auth plugin schema extension of `user` onto the remapped Auth list, not an unrelated host User', async () => {
|
|
112
|
+
// A better-auth provider plugin (e.g. the `anonymous` plugin) that ships a
|
|
113
|
+
// schema extension for the base `user` model.
|
|
114
|
+
const anonymousBetterAuthPlugin = {
|
|
115
|
+
id: 'anonymous',
|
|
116
|
+
schema: {
|
|
117
|
+
user: {
|
|
118
|
+
fields: {
|
|
119
|
+
isAnonymous: { type: 'boolean', required: false },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hostUserUpdate = vi.fn(() => false)
|
|
126
|
+
const result = await config({
|
|
127
|
+
db: { provider: 'sqlite' },
|
|
128
|
+
plugins: [
|
|
129
|
+
authPlugin({
|
|
130
|
+
user: { modelName: 'AuthUser' },
|
|
131
|
+
session: { modelName: 'AuthSession' },
|
|
132
|
+
account: { modelName: 'AuthAccount' },
|
|
133
|
+
verification: { modelName: 'AuthVerification' },
|
|
134
|
+
betterAuthPlugins: [anonymousBetterAuthPlugin],
|
|
135
|
+
}),
|
|
136
|
+
],
|
|
137
|
+
lists: {
|
|
138
|
+
// An app's own unrelated domain list that happens to share the
|
|
139
|
+
// default (unmapped) auth user key.
|
|
140
|
+
User: list({
|
|
141
|
+
fields: {
|
|
142
|
+
subjectId: text({ validation: { isRequired: true } }),
|
|
143
|
+
},
|
|
144
|
+
access: { operation: { update: hostUserUpdate } },
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// The plugin's schema extension lands on the adopted Auth list...
|
|
150
|
+
expect(result.lists.AuthUser.fields).toHaveProperty('isAnonymous')
|
|
151
|
+
expect(result.lists.AuthUser.fields).toHaveProperty('email')
|
|
152
|
+
|
|
153
|
+
// ...and the unrelated host `User` list is left completely untouched: no
|
|
154
|
+
// injected column, and its own (admin-only-style) access control survives
|
|
155
|
+
// instead of being overwritten by the plugin's permissive defaults.
|
|
156
|
+
const appUser = result.lists.User
|
|
157
|
+
expect(appUser.fields).toHaveProperty('subjectId')
|
|
158
|
+
expect(appUser.fields).not.toHaveProperty('isAnonymous')
|
|
159
|
+
expect(appUser.fields).not.toHaveProperty('email')
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- access control parameters are runtime values
|
|
161
|
+
expect(appUser.access?.operation?.update?.({} as any)).toBe(false)
|
|
162
|
+
expect(appUser.access?.operation?.update).toBe(hostUserUpdate)
|
|
163
|
+
})
|
|
74
164
|
})
|
|
75
165
|
|
|
76
166
|
describe('authPlugin - runtime user-key resolution', () => {
|
|
77
167
|
/**
|
|
78
|
-
* Build a minimal AccessContext whose `db`
|
|
79
|
-
*
|
|
168
|
+
* Build a minimal AccessContext whose non-sudo `db` always denies (returns
|
|
169
|
+
* null, as an access-controlled `context.db` read does), plus a separate
|
|
170
|
+
* `sudo` factory (passed as `plugin.runtime`'s second argument, NOT a
|
|
171
|
+
* method on `context` itself — see ADR-0013 / the `Plugin['runtime']` doc)
|
|
172
|
+
* whose `.db` records which model key was accessed and returns the real
|
|
173
|
+
* record. Since the User list ships closed by default (ADR-0013),
|
|
174
|
+
* getUser/getCurrentUser only resolve a real user if they go through
|
|
175
|
+
* `sudo()` rather than the plain `context.db`.
|
|
80
176
|
*/
|
|
81
177
|
function makeFakeContext(session: { userId?: string } | null) {
|
|
82
178
|
const accessedKeys: string[] = []
|
|
179
|
+
const sudoAccessedKeys: string[] = []
|
|
180
|
+
|
|
83
181
|
const db = new Proxy(
|
|
84
182
|
{},
|
|
85
183
|
{
|
|
86
184
|
get(_target, key: string) {
|
|
87
185
|
accessedKeys.push(key)
|
|
186
|
+
return { findUnique: async () => null }
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
const sudoDb = new Proxy(
|
|
191
|
+
{},
|
|
192
|
+
{
|
|
193
|
+
get(_target, key: string) {
|
|
194
|
+
sudoAccessedKeys.push(key)
|
|
88
195
|
return {
|
|
89
196
|
findUnique: async ({ where }: { where: { id: string } }) => ({
|
|
90
197
|
id: where.id,
|
|
@@ -94,44 +201,49 @@ describe('authPlugin - runtime user-key resolution', () => {
|
|
|
94
201
|
},
|
|
95
202
|
},
|
|
96
203
|
)
|
|
204
|
+
const sudoContext = { session, db: sudoDb, _isSudo: true } as unknown as AccessContext
|
|
97
205
|
const context = { session, db } as unknown as AccessContext
|
|
98
|
-
|
|
206
|
+
const sudo = () => sudoContext
|
|
207
|
+
return { context, sudo, accessedKeys, sudoAccessedKeys }
|
|
99
208
|
}
|
|
100
209
|
|
|
101
|
-
it('getUser
|
|
210
|
+
it('getUser resolves through sudo(), not the plain (closed-by-default) context.db', async () => {
|
|
102
211
|
const plugin = authPlugin({})
|
|
103
|
-
const { context, accessedKeys } = makeFakeContext({ userId: 'u1' })
|
|
104
|
-
const services = plugin.runtime?.(context) as AuthRuntimeServices
|
|
212
|
+
const { context, sudo, accessedKeys, sudoAccessedKeys } = makeFakeContext({ userId: 'u1' })
|
|
213
|
+
const services = plugin.runtime?.(context, sudo) as AuthRuntimeServices
|
|
105
214
|
|
|
106
|
-
|
|
107
|
-
expect(
|
|
215
|
+
const user = (await services.getUser('u1')) as { __model: string }
|
|
216
|
+
expect(sudoAccessedKeys).toContain('user')
|
|
217
|
+
expect(accessedKeys).not.toContain('user')
|
|
218
|
+
expect(user.__model).toBe('user')
|
|
108
219
|
})
|
|
109
220
|
|
|
110
221
|
it('getUser uses the configured user model db key (AuthUser -> authUser)', async () => {
|
|
111
222
|
const plugin = authPlugin({ user: { modelName: 'AuthUser' } })
|
|
112
|
-
const { context,
|
|
113
|
-
const services = plugin.runtime?.(context) as AuthRuntimeServices
|
|
223
|
+
const { context, sudo, sudoAccessedKeys } = makeFakeContext({ userId: 'u1' })
|
|
224
|
+
const services = plugin.runtime?.(context, sudo) as AuthRuntimeServices
|
|
114
225
|
|
|
115
226
|
const user = (await services.getUser('u1')) as { __model: string }
|
|
116
|
-
expect(
|
|
117
|
-
expect(
|
|
227
|
+
expect(sudoAccessedKeys).toContain('authUser')
|
|
228
|
+
expect(sudoAccessedKeys).not.toContain('user')
|
|
118
229
|
expect(user.__model).toBe('authUser')
|
|
119
230
|
})
|
|
120
231
|
|
|
121
|
-
it('getCurrentUser uses the configured user model db key', async () => {
|
|
232
|
+
it('getCurrentUser uses the configured user model db key via sudo()', async () => {
|
|
122
233
|
const plugin = authPlugin({ user: { modelName: 'AuthUser' } })
|
|
123
|
-
const { context, accessedKeys } = makeFakeContext({ userId: 'u1' })
|
|
124
|
-
const services = plugin.runtime?.(context) as AuthRuntimeServices
|
|
234
|
+
const { context, sudo, accessedKeys, sudoAccessedKeys } = makeFakeContext({ userId: 'u1' })
|
|
235
|
+
const services = plugin.runtime?.(context, sudo) as AuthRuntimeServices
|
|
125
236
|
|
|
126
237
|
const user = (await services.getCurrentUser()) as { __model: string }
|
|
127
|
-
expect(
|
|
238
|
+
expect(sudoAccessedKeys).toContain('authUser')
|
|
239
|
+
expect(accessedKeys).not.toContain('authUser')
|
|
128
240
|
expect(user.__model).toBe('authUser')
|
|
129
241
|
})
|
|
130
242
|
|
|
131
243
|
it('getCurrentUser returns null when there is no session', async () => {
|
|
132
244
|
const plugin = authPlugin({ user: { modelName: 'AuthUser' } })
|
|
133
|
-
const { context } = makeFakeContext(null)
|
|
134
|
-
const services = plugin.runtime?.(context) as AuthRuntimeServices
|
|
245
|
+
const { context, sudo } = makeFakeContext(null)
|
|
246
|
+
const services = plugin.runtime?.(context, sudo) as AuthRuntimeServices
|
|
135
247
|
|
|
136
248
|
expect(await services.getCurrentUser()).toBeNull()
|
|
137
249
|
})
|
|
@@ -138,7 +138,7 @@ describe('convertTableToList', () => {
|
|
|
138
138
|
expect(listConfig.fields.customField.type).toBe('text')
|
|
139
139
|
})
|
|
140
140
|
|
|
141
|
-
it('should
|
|
141
|
+
it('should ship the User table closed (no access control) per ADR-0013', () => {
|
|
142
142
|
const tableSchema = {
|
|
143
143
|
modelName: 'User',
|
|
144
144
|
fields: {
|
|
@@ -148,12 +148,10 @@ describe('convertTableToList', () => {
|
|
|
148
148
|
|
|
149
149
|
const listConfig = convertTableToList('user', tableSchema)
|
|
150
150
|
|
|
151
|
-
expect(listConfig.access).
|
|
152
|
-
expect(listConfig.access?.operation?.query?.({})).toBe(true)
|
|
153
|
-
expect(listConfig.access?.operation?.create?.({})).toBe(true)
|
|
151
|
+
expect(listConfig.access).toBeUndefined()
|
|
154
152
|
})
|
|
155
153
|
|
|
156
|
-
it('should
|
|
154
|
+
it('should ship the Session table closed (no access control) per ADR-0013', () => {
|
|
157
155
|
const tableSchema = {
|
|
158
156
|
modelName: 'Session',
|
|
159
157
|
fields: {
|
|
@@ -163,12 +161,10 @@ describe('convertTableToList', () => {
|
|
|
163
161
|
|
|
164
162
|
const listConfig = convertTableToList('session', tableSchema)
|
|
165
163
|
|
|
166
|
-
expect(listConfig.access
|
|
167
|
-
expect(listConfig.access?.operation?.create?.({})).toBe(true)
|
|
168
|
-
expect(listConfig.access?.operation?.update?.({})).toBe(false)
|
|
164
|
+
expect(listConfig.access).toBeUndefined()
|
|
169
165
|
})
|
|
170
166
|
|
|
171
|
-
it('should
|
|
167
|
+
it('should ship the Verification table closed (no access control) per ADR-0013', () => {
|
|
172
168
|
const tableSchema = {
|
|
173
169
|
modelName: 'Verification',
|
|
174
170
|
fields: {
|
|
@@ -178,13 +174,10 @@ describe('convertTableToList', () => {
|
|
|
178
174
|
|
|
179
175
|
const listConfig = convertTableToList('verification', tableSchema)
|
|
180
176
|
|
|
181
|
-
expect(listConfig.access
|
|
182
|
-
expect(listConfig.access?.operation?.create?.({})).toBe(true)
|
|
183
|
-
expect(listConfig.access?.operation?.update?.({})).toBe(false)
|
|
184
|
-
expect(listConfig.access?.operation?.delete?.({})).toBe(true)
|
|
177
|
+
expect(listConfig.access).toBeUndefined()
|
|
185
178
|
})
|
|
186
179
|
|
|
187
|
-
it('should
|
|
180
|
+
it('should ship unknown tables closed (no access control)', () => {
|
|
188
181
|
const tableSchema = {
|
|
189
182
|
modelName: 'UnknownTable',
|
|
190
183
|
fields: {
|
|
@@ -194,13 +187,10 @@ describe('convertTableToList', () => {
|
|
|
194
187
|
|
|
195
188
|
const listConfig = convertTableToList('unknown_table', tableSchema)
|
|
196
189
|
|
|
197
|
-
expect(listConfig.access
|
|
198
|
-
expect(listConfig.access?.operation?.create?.({})).toBe(false)
|
|
199
|
-
expect(listConfig.access?.operation?.update?.({})).toBe(false)
|
|
200
|
-
expect(listConfig.access?.operation?.delete?.({})).toBe(false)
|
|
190
|
+
expect(listConfig.access).toBeUndefined()
|
|
201
191
|
})
|
|
202
192
|
|
|
203
|
-
it('should
|
|
193
|
+
it('should ship OAuth application table closed (no access control)', () => {
|
|
204
194
|
const tableSchema = {
|
|
205
195
|
modelName: 'OAuthApplication',
|
|
206
196
|
fields: {
|
|
@@ -211,8 +201,7 @@ describe('convertTableToList', () => {
|
|
|
211
201
|
|
|
212
202
|
const listConfig = convertTableToList('oauthapplication', tableSchema)
|
|
213
203
|
|
|
214
|
-
expect(listConfig.access
|
|
215
|
-
expect(listConfig.access?.operation?.create?.({})).toBe(true)
|
|
204
|
+
expect(listConfig.access).toBeUndefined()
|
|
216
205
|
})
|
|
217
206
|
})
|
|
218
207
|
|
|
@@ -276,6 +265,54 @@ describe('convertBetterAuthSchema', () => {
|
|
|
276
265
|
expect(lists).toEqual({})
|
|
277
266
|
})
|
|
278
267
|
|
|
268
|
+
it('should resolve a base-model table against the configured baseModelKeys remap instead of the table name', () => {
|
|
269
|
+
const schema = {
|
|
270
|
+
user: {
|
|
271
|
+
modelName: '',
|
|
272
|
+
fields: {
|
|
273
|
+
isAnonymous: { type: 'boolean' },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const lists = convertBetterAuthSchema(schema, { user: 'AuthUser' })
|
|
279
|
+
|
|
280
|
+
expect(lists).toHaveProperty('AuthUser')
|
|
281
|
+
expect(lists).not.toHaveProperty('User')
|
|
282
|
+
expect(lists.AuthUser.fields).toHaveProperty('isAnonymous')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('should prefer the baseModelKeys remap over an explicit tableSchema.modelName', () => {
|
|
286
|
+
const schema = {
|
|
287
|
+
user: {
|
|
288
|
+
modelName: 'User',
|
|
289
|
+
fields: {
|
|
290
|
+
isAnonymous: { type: 'boolean' },
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const lists = convertBetterAuthSchema(schema, { user: 'AuthUser' })
|
|
296
|
+
|
|
297
|
+
expect(lists).toHaveProperty('AuthUser')
|
|
298
|
+
expect(lists).not.toHaveProperty('User')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('should leave non-base tables unaffected by baseModelKeys', () => {
|
|
302
|
+
const schema = {
|
|
303
|
+
oauth_application: {
|
|
304
|
+
modelName: '',
|
|
305
|
+
fields: {
|
|
306
|
+
data: { type: 'string' },
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const lists = convertBetterAuthSchema(schema, { user: 'AuthUser' })
|
|
312
|
+
|
|
313
|
+
expect(lists).toHaveProperty('OauthApplication')
|
|
314
|
+
})
|
|
315
|
+
|
|
279
316
|
it('should convert complex schema with multiple field types', () => {
|
|
280
317
|
const schema = {
|
|
281
318
|
test_table: {
|