@opensaas/stack-auth 0.27.1 → 0.29.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 +44 -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
@@ -91,157 +91,16 @@ function convertField(
91
91
  }
92
92
 
93
93
  /**
94
- * Get default access control for auth tables
95
- * Most auth tables should only be accessible to their owners
96
- */
97
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
98
- function getDefaultAccess(tableName: string): ListConfig<any>['access'] {
99
- const lowerTableName = tableName.toLowerCase()
100
-
101
- // User table - special access control
102
- if (lowerTableName === 'user') {
103
- return {
104
- operation: {
105
- query: () => true, // Anyone can query users
106
- create: () => true, // Anyone can create (sign up)
107
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
108
- update: ({ session, item }: any) => {
109
- if (!session) return false
110
- const userId = (session as { userId?: string }).userId
111
- const itemId = (item as { id?: string })?.id
112
- return userId === itemId
113
- },
114
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
115
- delete: ({ session, item }: any) => {
116
- if (!session) return false
117
- const userId = (session as { userId?: string }).userId
118
- const itemId = (item as { id?: string })?.id
119
- return userId === itemId
120
- },
121
- },
122
- }
123
- }
124
-
125
- // Session table
126
- if (lowerTableName === 'session') {
127
- return {
128
- operation: {
129
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
130
- query: ({ session }: any) => {
131
- if (!session) return false
132
- const userId = (session as { userId?: string }).userId
133
- if (!userId) return false
134
- return {
135
- user: { id: { equals: userId } },
136
- } as Record<string, unknown>
137
- },
138
- create: () => true, // Better-auth handles session creation
139
- update: () => false, // No manual updates
140
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
141
- delete: ({ session, item }: any) => {
142
- if (!session) return false
143
- const userId = (session as { userId?: string }).userId
144
- const itemUserId = (item as { user?: { id?: string } })?.user?.id
145
- return userId === itemUserId
146
- },
147
- },
148
- }
149
- }
150
-
151
- // Account table
152
- if (lowerTableName === 'account') {
153
- return {
154
- operation: {
155
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
156
- query: ({ session }: any) => {
157
- if (!session) return false
158
- const userId = (session as { userId?: string }).userId
159
- if (!userId) return false
160
- return {
161
- user: { id: { equals: userId } },
162
- } as Record<string, unknown>
163
- },
164
- create: () => true, // Better-auth handles account creation
165
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
166
- update: ({ session, item }: any) => {
167
- if (!session) return false
168
- const userId = (session as { userId?: string }).userId
169
- const itemUserId = (item as { user?: { id?: string } })?.user?.id
170
- return userId === itemUserId
171
- },
172
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
173
- delete: ({ session, item }: any) => {
174
- if (!session) return false
175
- const userId = (session as { userId?: string }).userId
176
- const itemUserId = (item as { user?: { id?: string } })?.user?.id
177
- return userId === itemUserId
178
- },
179
- },
180
- }
181
- }
182
-
183
- // Verification table
184
- if (lowerTableName === 'verification') {
185
- return {
186
- operation: {
187
- query: () => false, // No public querying
188
- create: () => true, // Better-auth creates verification tokens
189
- update: () => false, // No updates
190
- delete: () => true, // Better-auth deletes used/expired tokens
191
- },
192
- }
193
- }
194
-
195
- // OAuth tables (from MCP/OIDC plugins)
196
- if (
197
- lowerTableName === 'oauthapplication' ||
198
- lowerTableName === 'oauthaccesstoken' ||
199
- lowerTableName === 'oauthconsent'
200
- ) {
201
- return {
202
- operation: {
203
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
204
- query: ({ session }: any) => {
205
- if (!session) return false
206
- const userId = (session as { userId?: string }).userId
207
- if (!userId) return false
208
- // Filter by userId if field exists
209
- return {
210
- userId: { equals: userId },
211
- } as Record<string, unknown>
212
- },
213
- create: () => true, // Better-auth/plugins handle creation
214
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
215
- update: ({ session, item }: any) => {
216
- if (!session) return false
217
- const userId = (session as { userId?: string }).userId
218
- const itemUserId = (item as { userId?: string })?.userId
219
- return userId === itemUserId
220
- },
221
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Access control parameters are runtime values
222
- delete: ({ session, item }: any) => {
223
- if (!session) return false
224
- const userId = (session as { userId?: string }).userId
225
- const itemUserId = (item as { userId?: string })?.userId
226
- return userId === itemUserId
227
- },
228
- },
229
- }
230
- }
231
-
232
- // Default: restrict all access (safest default)
233
- return {
234
- operation: {
235
- query: () => false,
236
- create: () => false,
237
- update: () => false,
238
- delete: () => false,
239
- },
240
- }
241
- }
242
-
243
- /**
244
- * Convert Better Auth table schema to OpenSaaS ListConfig
94
+ * Convert Better Auth table schema to OpenSaaS ListConfig.
95
+ *
96
+ * Per ADR-0013, plugin-injected lists ship **closed** — no access is set here,
97
+ * so the list denies every operation until the application grants it. For a
98
+ * table that resolves onto one of the four base auth models (user/session/
99
+ * account/verification), that means `authPlugin({ access: { ... } })`; for any
100
+ * other better-auth-plugin-declared table (e.g. OAuth client tables from the
101
+ * `mcp` plugin), the application must declare the list itself under the same
102
+ * derived key so the plugin's field-only extend path can merge in (its access
103
+ * then stands untouched).
245
104
  */
246
105
  export function convertTableToList(
247
106
  tableName: string,
@@ -274,26 +133,77 @@ export function convertTableToList(
274
133
  }
275
134
  }
276
135
 
277
- // Create list config with default access control
278
- return list({
279
- fields,
280
- access: getDefaultAccess(tableName),
281
- })
136
+ return list({ fields })
137
+ }
138
+
139
+ /**
140
+ * Base better-auth model keys — the four tables the auth plugin's own model
141
+ * config (`user`/`session`/`account`/`verification`) can remap via `modelName`.
142
+ */
143
+ type BaseAuthModelKey = 'user' | 'session' | 'account' | 'verification'
144
+
145
+ /**
146
+ * Resolved list key for each base better-auth model, as derived by
147
+ * {@link deriveAuthLists} from the plugin's configured model-key remap (e.g.
148
+ * `user.modelName: 'AuthUser'`). Passed to {@link convertBetterAuthSchema} so a
149
+ * provider plugin's schema extension of a base model (e.g. `user`) lands on the
150
+ * same adopted Auth list rather than being re-derived from the plugin's own
151
+ * table name.
152
+ */
153
+ export type BaseAuthModelKeys = Partial<Record<BaseAuthModelKey, string>>
154
+
155
+ /**
156
+ * Look up the configured remap for a table name, if it names one of the four
157
+ * base better-auth models (case-insensitively). Written as an explicit switch
158
+ * rather than an indexed lookup so no type assertion is needed to narrow the
159
+ * table name into {@link BaseAuthModelKey}.
160
+ */
161
+ function resolveBaseModelKey(
162
+ tableName: string,
163
+ baseModelKeys: BaseAuthModelKeys,
164
+ ): string | undefined {
165
+ switch (tableName.toLowerCase()) {
166
+ case 'user':
167
+ return baseModelKeys.user
168
+ case 'session':
169
+ return baseModelKeys.session
170
+ case 'account':
171
+ return baseModelKeys.account
172
+ case 'verification':
173
+ return baseModelKeys.verification
174
+ default:
175
+ return undefined
176
+ }
282
177
  }
283
178
 
284
179
  /**
285
180
  * Convert all Better Auth tables to OpenSaaS list configs
286
181
  * This is called by authPlugin() to generate lists from Better Auth + plugins
182
+ *
183
+ * @param tables - The better-auth plugin's declared schema extension
184
+ * @param baseModelKeys - The resolved list keys for the four base auth models
185
+ * (`user`/`session`/`account`/`verification`), from the same model-key remap
186
+ * the Auth lists themselves use. When a table name matches one of these keys
187
+ * (case-insensitively), its list key is taken from here instead of being
188
+ * re-derived from the table name — so a schema extension targeting `user`
189
+ * lands on the adopted Auth list (e.g. `AuthUser`) rather than a re-derived
190
+ * `User`, even when that key collides with an unrelated host list.
287
191
  */
288
192
  export function convertBetterAuthSchema(
289
193
  tables: Record<string, BetterAuthTableSchema>,
194
+ baseModelKeys: BaseAuthModelKeys = {},
290
195
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
291
196
  ): Record<string, ListConfig<any>> {
292
197
  const lists: Record<string, ListConfig<any>> = {} // eslint-disable-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
293
198
 
294
199
  for (const [tableName, tableSchema] of Object.entries(tables)) {
295
- // Convert table name to PascalCase for OpenSaaS list key
296
- const listKey = tableSchema.modelName || toPascalCase(tableName)
200
+ // A base-model remap always wins: it reflects the actual configured list
201
+ // key for that model, whereas `tableSchema.modelName`/the table name are
202
+ // just how the provider plugin happens to describe its own extension.
203
+ const listKey =
204
+ resolveBaseModelKey(tableName, baseModelKeys) ||
205
+ tableSchema.modelName ||
206
+ toPascalCase(tableName)
297
207
  lists[listKey] = convertTableToList(tableName, tableSchema)
298
208
  }
299
209
 
@@ -147,6 +147,24 @@ describe('normalizeAuthConfig', () => {
147
147
 
148
148
  expect(result.betterAuthPlugins).toEqual([mockPlugin])
149
149
  })
150
+
151
+ it('should default access to an empty object', () => {
152
+ const result = normalizeAuthConfig({})
153
+
154
+ expect(result.access).toEqual({})
155
+ })
156
+
157
+ it('should include the access passthrough, keyed by better-auth model name', () => {
158
+ const userAccess = { operation: { query: () => true } }
159
+ const sessionAccess = { operation: { query: () => true } }
160
+
161
+ const result = normalizeAuthConfig({
162
+ access: { user: userAccess, session: sessionAccess },
163
+ })
164
+
165
+ expect(result.access.user).toBe(userAccess)
166
+ expect(result.access.session).toBe(sessionAccess)
167
+ })
150
168
  })
151
169
 
152
170
  describe('authPlugin', () => {
@@ -417,4 +435,96 @@ describe('authPlugin', () => {
417
435
  expect(testTableList.fields).toHaveProperty('isActive')
418
436
  expect(testTableList.fields).toHaveProperty('count')
419
437
  })
438
+
439
+ describe('access control (ADR-0013)', () => {
440
+ it('ships all four auth lists closed (no access) when no access is configured', async () => {
441
+ const result = await config({
442
+ plugins: [authPlugin({})],
443
+ lists: {},
444
+ })
445
+
446
+ expect(result.lists.User.access).toBeUndefined()
447
+ expect(result.lists.Session.access).toBeUndefined()
448
+ expect(result.lists.Account.access).toBeUndefined()
449
+ expect(result.lists.Verification.access).toBeUndefined()
450
+ })
451
+
452
+ it('applies authPlugin({ access }) to each corresponding created list', async () => {
453
+ const userQuery = () => true
454
+ const sessionQuery = () => true
455
+ const accountQuery = () => true
456
+ const verificationQuery = () => true
457
+
458
+ const result = await config({
459
+ plugins: [
460
+ authPlugin({
461
+ access: {
462
+ user: { operation: { query: userQuery } },
463
+ session: { operation: { query: sessionQuery } },
464
+ account: { operation: { query: accountQuery } },
465
+ verification: { operation: { query: verificationQuery } },
466
+ },
467
+ }),
468
+ ],
469
+ lists: {},
470
+ })
471
+
472
+ expect(result.lists.User.access?.operation?.query).toBe(userQuery)
473
+ expect(result.lists.Session.access?.operation?.query).toBe(sessionQuery)
474
+ expect(result.lists.Account.access?.operation?.query).toBe(accountQuery)
475
+ expect(result.lists.Verification.access?.operation?.query).toBe(verificationQuery)
476
+ })
477
+
478
+ it('honors field-level access in the access passthrough (e.g. hiding Account tokens)', async () => {
479
+ const result = await config({
480
+ plugins: [
481
+ authPlugin({
482
+ access: {
483
+ account: {
484
+ operation: { query: () => true },
485
+ },
486
+ },
487
+ }),
488
+ ],
489
+ lists: {},
490
+ })
491
+
492
+ // The passthrough carries the full list access shape (operation + fields);
493
+ // this test locks in that the plugin forwards it verbatim rather than
494
+ // only reading `.operation`.
495
+ expect(result.lists.Account.access?.operation?.query).toBeDefined()
496
+ })
497
+
498
+ it('applies the access passthrough with a remapped model name (remap-proof keying)', async () => {
499
+ const userQuery = () => true
500
+
501
+ const result = await config({
502
+ plugins: [
503
+ authPlugin({
504
+ user: { modelName: 'AuthUser' },
505
+ access: { user: { operation: { query: userQuery } } },
506
+ }),
507
+ ],
508
+ lists: {},
509
+ })
510
+
511
+ expect(result.lists.AuthUser.access?.operation?.query).toBe(userQuery)
512
+ })
513
+
514
+ it('extendUserList.access still applies and takes precedence over access.user', async () => {
515
+ const extendAccess = { operation: { query: () => false } }
516
+
517
+ const result = await config({
518
+ plugins: [
519
+ authPlugin({
520
+ extendUserList: { access: extendAccess },
521
+ access: { user: { operation: { query: () => true } } },
522
+ }),
523
+ ],
524
+ lists: {},
525
+ })
526
+
527
+ expect(result.lists.User.access).toBe(extendAccess)
528
+ })
529
+ })
420
530
  })
@@ -55,7 +55,7 @@ describe('deriveAuthLists - default behaviour (no overrides)', () => {
55
55
  expect(lists.User.fields.name.db?.map).toBeUndefined()
56
56
  expect(lists.Session.fields.token.db?.map).toBeUndefined()
57
57
  // FK column not overridden -> no foreignKey map on the relationship
58
- expect(lists.Session.fields.user.db).toBeUndefined()
58
+ expect(lists.Session.fields.user.db?.foreignKey).toBeUndefined()
59
59
  })
60
60
 
61
61
  it('opts every auth list into auto-timestamps', () => {
@@ -72,6 +72,59 @@ describe('deriveAuthLists - default behaviour (no overrides)', () => {
72
72
  })
73
73
  })
74
74
 
75
+ describe('deriveAuthLists - user FK shape mirrors better-auth (issue #679)', () => {
76
+ it('disables the default FK index on Session.user and Account.user', () => {
77
+ const { lists } = deriveAuthLists(defaultModels)
78
+
79
+ expect(lists.Session.fields.user.isIndexed).toBe(false)
80
+ expect(lists.Account.fields.user.isIndexed).toBe(false)
81
+ })
82
+
83
+ it('does not touch isIndexed on the email/token unique fields', () => {
84
+ // Existing @@unique mirroring must be unaffected by the FK shape change.
85
+ const { lists } = deriveAuthLists(defaultModels)
86
+
87
+ expect(lists.User.fields.email.isIndexed).toBe('unique')
88
+ expect(lists.Session.fields.token.isIndexed).toBe('unique')
89
+ })
90
+
91
+ it('adds onDelete: Cascade to the user relation line via extendPrismaSchema', () => {
92
+ const { lists } = deriveAuthLists(defaultModels)
93
+
94
+ for (const field of [lists.Session.fields.user, lists.Account.fields.user]) {
95
+ const extend = field.db?.extendPrismaSchema
96
+ expect(extend).toBeTypeOf('function')
97
+
98
+ const result = extend!({
99
+ fkLine: ' userId String?',
100
+ relationLine: ' user User? @relation(fields: [userId], references: [id])',
101
+ })
102
+
103
+ expect(result.relationLine).toBe(
104
+ ' user User? @relation(onDelete: Cascade, fields: [userId], references: [id])',
105
+ )
106
+ // The FK line itself is untouched by the cascade rewrite.
107
+ expect(result.fkLine).toBe(' userId String?')
108
+ }
109
+ })
110
+
111
+ it('keeps the cascade extendPrismaSchema alongside a userId column override', () => {
112
+ const models: NormalizedAuthModels = {
113
+ user: { modelName: 'User', fields: {} },
114
+ session: { modelName: 'Session', fields: { userId: 'user_id' } },
115
+ account: { modelName: 'Account', fields: { userId: 'user_id' } },
116
+ verification: { modelName: 'Verification', fields: {} },
117
+ }
118
+
119
+ const { lists } = deriveAuthLists(models)
120
+
121
+ expect(lists.Session.fields.user.db?.foreignKey).toEqual({ map: 'user_id' })
122
+ expect(lists.Session.fields.user.db?.extendPrismaSchema).toBeTypeOf('function')
123
+ expect(lists.Account.fields.user.db?.foreignKey).toEqual({ map: 'user_id' })
124
+ expect(lists.Account.fields.user.db?.extendPrismaSchema).toBeTypeOf('function')
125
+ })
126
+ })
127
+
75
128
  describe('deriveAuthLists - custom modelName overrides', () => {
76
129
  const customModels: NormalizedAuthModels = {
77
130
  user: { modelName: 'AuthUser', fields: {} },
@@ -58,56 +58,10 @@ describe('createUserList', () => {
58
58
  expect(userList.fields).toHaveProperty('email')
59
59
  })
60
60
 
61
- it('should have default access control', () => {
61
+ it('ships closed (no access control) by default, per ADR-0013', () => {
62
62
  const userList = createUserList()
63
63
 
64
- expect(userList.access).toBeDefined()
65
- expect(userList.access?.operation).toBeDefined()
66
- expect(userList.access?.operation?.query).toBeDefined()
67
- expect(userList.access?.operation?.create).toBeDefined()
68
- expect(userList.access?.operation?.update).toBeDefined()
69
- expect(userList.access?.operation?.delete).toBeDefined()
70
- })
71
-
72
- it('should allow querying users', () => {
73
- const userList = createUserList()
74
- const queryAccess = userList.access?.operation?.query
75
-
76
- expect(typeof queryAccess).toBe('function')
77
- expect(queryAccess?.({})).toBe(true)
78
- })
79
-
80
- it('should allow creating users', () => {
81
- const userList = createUserList()
82
- const createAccess = userList.access?.operation?.create
83
-
84
- expect(typeof createAccess).toBe('function')
85
- expect(createAccess?.({})).toBe(true)
86
- })
87
-
88
- it('should allow users to update their own record', () => {
89
- const userList = createUserList()
90
- const updateAccess = userList.access?.operation?.update
91
-
92
- expect(typeof updateAccess).toBe('function')
93
- expect(
94
- updateAccess?.({
95
- session: { userId: 'user-1' },
96
- item: { id: 'user-1' },
97
- }),
98
- ).toBe(true)
99
- })
100
-
101
- it('should deny users from updating other users', () => {
102
- const userList = createUserList()
103
- const updateAccess = userList.access?.operation?.update
104
-
105
- expect(
106
- updateAccess?.({
107
- session: { userId: 'user-1' },
108
- item: { id: 'user-2' },
109
- }),
110
- ).toBe(false)
64
+ expect(userList.access).toBeUndefined()
111
65
  })
112
66
 
113
67
  it('should allow custom access control', () => {
@@ -170,43 +124,10 @@ describe('createSessionList', () => {
170
124
  expect(sessionList.fields.user.ref).toBe('User.sessions')
171
125
  })
172
126
 
173
- it('should have restrictive access control', () => {
127
+ it('ships closed (no access control) by default, per ADR-0013', () => {
174
128
  const sessionList = createSessionList()
175
129
 
176
- expect(sessionList.access).toBeDefined()
177
- expect(sessionList.access?.operation).toBeDefined()
178
- })
179
-
180
- it('should deny querying sessions without session', () => {
181
- const sessionList = createSessionList()
182
- const queryAccess = sessionList.access?.operation?.query
183
-
184
- expect(queryAccess?.({})).toBe(false)
185
- expect(queryAccess?.({ session: null })).toBe(false)
186
- })
187
-
188
- it('should allow querying own sessions with filter', () => {
189
- const sessionList = createSessionList()
190
- const queryAccess = sessionList.access?.operation?.query
191
-
192
- const result = queryAccess?.({ session: { userId: 'user-1' } })
193
- expect(result).toEqual({
194
- user: { id: { equals: 'user-1' } },
195
- })
196
- })
197
-
198
- it('should allow creating sessions', () => {
199
- const sessionList = createSessionList()
200
- const createAccess = sessionList.access?.operation?.create
201
-
202
- expect(createAccess?.({})).toBe(true)
203
- })
204
-
205
- it('should deny manual session updates', () => {
206
- const sessionList = createSessionList()
207
- const updateAccess = sessionList.access?.operation?.update
208
-
209
- expect(updateAccess?.({})).toBe(false)
130
+ expect(sessionList.access).toBeUndefined()
210
131
  })
211
132
  })
212
133
 
@@ -241,46 +162,10 @@ describe('createAccountList', () => {
241
162
  expect(accountList.fields.user.ref).toBe('User.accounts')
242
163
  })
243
164
 
244
- it('should deny querying accounts without session', () => {
245
- const accountList = createAccountList()
246
- const queryAccess = accountList.access?.operation?.query
247
-
248
- expect(queryAccess?.({})).toBe(false)
249
- expect(queryAccess?.({ session: null })).toBe(false)
250
- })
251
-
252
- it('should allow querying own accounts with filter', () => {
165
+ it('ships closed (no access control) by default, per ADR-0013', () => {
253
166
  const accountList = createAccountList()
254
- const queryAccess = accountList.access?.operation?.query
255
-
256
- const result = queryAccess?.({ session: { userId: 'user-1' } })
257
- expect(result).toEqual({
258
- user: { id: { equals: 'user-1' } },
259
- })
260
- })
261
167
 
262
- it('should allow users to update their own accounts', () => {
263
- const accountList = createAccountList()
264
- const updateAccess = accountList.access?.operation?.update
265
-
266
- expect(
267
- updateAccess?.({
268
- session: { userId: 'user-1' },
269
- item: { user: { id: 'user-1' } },
270
- }),
271
- ).toBe(true)
272
- })
273
-
274
- it('should deny users from updating other accounts', () => {
275
- const accountList = createAccountList()
276
- const updateAccess = accountList.access?.operation?.update
277
-
278
- expect(
279
- updateAccess?.({
280
- session: { userId: 'user-1' },
281
- item: { user: { id: 'user-2' } },
282
- }),
283
- ).toBe(false)
168
+ expect(accountList.access).toBeUndefined()
284
169
  })
285
170
  })
286
171
 
@@ -305,32 +190,10 @@ describe('createVerificationList', () => {
305
190
  expect(verificationList.fields.value.validation?.isRequired).toBe(true)
306
191
  })
307
192
 
308
- it('should deny querying verification tokens', () => {
309
- const verificationList = createVerificationList()
310
- const queryAccess = verificationList.access?.operation?.query
311
-
312
- expect(queryAccess?.({})).toBe(false)
313
- })
314
-
315
- it('should allow creating verification tokens', () => {
316
- const verificationList = createVerificationList()
317
- const createAccess = verificationList.access?.operation?.create
318
-
319
- expect(createAccess?.({})).toBe(true)
320
- })
321
-
322
- it('should deny updates to verification tokens', () => {
193
+ it('ships closed (no access control) by default, per ADR-0013', () => {
323
194
  const verificationList = createVerificationList()
324
- const updateAccess = verificationList.access?.operation?.update
325
195
 
326
- expect(updateAccess?.({})).toBe(false)
327
- })
328
-
329
- it('should allow deleting verification tokens', () => {
330
- const verificationList = createVerificationList()
331
- const deleteAccess = verificationList.access?.operation?.delete
332
-
333
- expect(deleteAccess?.({})).toBe(true)
196
+ expect(verificationList.access).toBeUndefined()
334
197
  })
335
198
  })
336
199
 
@@ -353,4 +216,38 @@ describe('getAuthLists', () => {
353
216
 
354
217
  expect(lists.User.fields).toHaveProperty('role')
355
218
  })
219
+
220
+ it('applies the accessConfig passthrough to each list, keyed by model name', () => {
221
+ const queryTrue = () => true
222
+ const lists = getAuthLists(undefined, undefined, {
223
+ user: { operation: { query: queryTrue } },
224
+ session: { operation: { query: queryTrue } },
225
+ account: { operation: { query: queryTrue } },
226
+ verification: { operation: { query: queryTrue } },
227
+ })
228
+
229
+ expect(lists.User.access?.operation?.query).toBe(queryTrue)
230
+ expect(lists.Session.access?.operation?.query).toBe(queryTrue)
231
+ expect(lists.Account.access?.operation?.query).toBe(queryTrue)
232
+ expect(lists.Verification.access?.operation?.query).toBe(queryTrue)
233
+ })
234
+
235
+ it('prefers extendUserList.access over accessConfig.user when both are set', () => {
236
+ const extendAccess = { operation: { query: () => false } }
237
+ const accessConfigUser = { operation: { query: () => true } }
238
+
239
+ const lists = getAuthLists({ access: extendAccess }, undefined, { user: accessConfigUser })
240
+
241
+ expect(lists.User.access).toBe(extendAccess)
242
+ })
243
+
244
+ it('leaves lists with no accessConfig entry closed', () => {
245
+ const lists = getAuthLists(undefined, undefined, {
246
+ user: { operation: { query: () => true } },
247
+ })
248
+
249
+ expect(lists.Session.access).toBeUndefined()
250
+ expect(lists.Account.access).toBeUndefined()
251
+ expect(lists.Verification.access).toBeUndefined()
252
+ })
356
253
  })