@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.
Files changed (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +42 -0
  3. package/CLAUDE.md +35 -1
  4. package/dist/config/derive-auth-lists.d.ts +15 -5
  5. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  6. package/dist/config/derive-auth-lists.js +59 -98
  7. package/dist/config/derive-auth-lists.js.map +1 -1
  8. package/dist/config/index.d.ts.map +1 -1
  9. package/dist/config/index.js +1 -0
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/config/plugin.d.ts.map +1 -1
  12. package/dist/config/plugin.js +31 -13
  13. package/dist/config/plugin.js.map +1 -1
  14. package/dist/config/types.d.ts +58 -0
  15. package/dist/config/types.d.ts.map +1 -1
  16. package/dist/lists/index.d.ts +9 -4
  17. package/dist/lists/index.d.ts.map +1 -1
  18. package/dist/lists/index.js +3 -2
  19. package/dist/lists/index.js.map +1 -1
  20. package/dist/runtime/types.d.ts +7 -5
  21. package/dist/runtime/types.d.ts.map +1 -1
  22. package/dist/server/schema-converter.d.ts +34 -2
  23. package/dist/server/schema-converter.d.ts.map +1 -1
  24. package/dist/server/schema-converter.js +47 -163
  25. package/dist/server/schema-converter.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/config/derive-auth-lists.ts +61 -87
  28. package/src/config/index.ts +1 -0
  29. package/src/config/plugin.ts +36 -13
  30. package/src/config/types.ts +64 -0
  31. package/src/lists/index.ts +10 -4
  32. package/src/runtime/types.ts +7 -5
  33. package/src/server/schema-converter.ts +68 -158
  34. package/tests/config.test.ts +110 -0
  35. package/tests/derive-auth-lists.test.ts +54 -1
  36. package/tests/lists.test.ts +42 -145
  37. package/tests/plugin-derived-keys.test.ts +130 -18
  38. package/tests/schema-converter.test.ts +58 -21
  39. 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` records which model key was
79
- * accessed, so we can assert the runtime resolves the configured user model.
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
- return { context, accessedKeys }
206
+ const sudo = () => sudoContext
207
+ return { context, sudo, accessedKeys, sudoAccessedKeys }
99
208
  }
100
209
 
101
- it('getUser uses the default `user` db key when no modelName override', () => {
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
- void services.getUser('u1')
107
- expect(accessedKeys).toContain('user')
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, accessedKeys } = makeFakeContext({ userId: 'u1' })
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(accessedKeys).toContain('authUser')
117
- expect(accessedKeys).not.toContain('user')
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(accessedKeys).toContain('authUser')
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 apply default access control for User table', () => {
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).toBeDefined()
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 apply default access control for Session table', () => {
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?.operation?.query?.({ session: null })).toBe(false)
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 apply default access control for Verification table', () => {
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?.operation?.query?.({})).toBe(false)
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 apply restrictive default access for unknown tables', () => {
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?.operation?.query?.({})).toBe(false)
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 handle OAuth application table', () => {
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?.operation?.query?.({ session: null })).toBe(false)
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: {