@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
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
* - relationship refs between the auth lists wired to the *derived* keys
|
|
14
14
|
* (e.g. `Session.user → AuthUser.sessions`)
|
|
15
15
|
*
|
|
16
|
-
* When the developer supplies no `modelName`/`fields` overrides, the output
|
|
17
|
-
*
|
|
18
|
-
* `Verification`
|
|
16
|
+
* When the developer supplies no `modelName`/`fields` overrides, the output
|
|
17
|
+
* keeps the historical default keys (`User`/`Session`/`Account`/
|
|
18
|
+
* `Verification`) and field shapes — see the unit tests. Per ADR-0013, each
|
|
19
|
+
* list ships with **no** operation-level access unless the caller supplies it
|
|
20
|
+
* (`accessConfig`, or `userConfig.access` for the user list) — deny-by-default,
|
|
21
|
+
* not the plugin's former permissive defaults.
|
|
19
22
|
*
|
|
20
23
|
* `getAuthLists`/`convertBetterAuthSchema` (and the runtime user-key
|
|
21
24
|
* resolution) consume this module so derivation lives in exactly one place.
|
|
@@ -24,8 +27,9 @@
|
|
|
24
27
|
import { list } from '@opensaas/stack-core'
|
|
25
28
|
import { text, timestamp, checkbox, relationship } from '@opensaas/stack-core/fields'
|
|
26
29
|
import type { ListConfig } from '@opensaas/stack-core'
|
|
30
|
+
import type { RelationshipField } from '@opensaas/stack-core/fields'
|
|
27
31
|
import type { ExtendUserListConfig } from '../lists/index.js'
|
|
28
|
-
import type { NormalizedAuthModelConfig, NormalizedAuthModels } from './types.js'
|
|
32
|
+
import type { AuthAccessConfig, NormalizedAuthModelConfig, NormalizedAuthModels } from './types.js'
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* Default better-auth model names — used to decide whether a `@@map` is needed
|
|
@@ -103,24 +107,39 @@ function fieldDb(fieldName: string, fields: Record<string, string>): { map: stri
|
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
/**
|
|
106
|
-
* Build the
|
|
107
|
-
* `userId` column override from the better-auth
|
|
110
|
+
* Build the `db` config for a `user` relationship (`Session.user` /
|
|
111
|
+
* `Account.user`), honouring a `userId` column override from the better-auth
|
|
112
|
+
* `fields` map and mirroring better-auth's own FK shape: no separate FK index
|
|
113
|
+
* — the index is applied at the field level via `isIndexed: false` — and
|
|
114
|
+
* `onDelete: Cascade`, so a generated Auth schema diffs clean against a live
|
|
115
|
+
* better-auth database on both dimensions instead of showing a spurious index
|
|
116
|
+
* drop and a referential-action change (issue #679).
|
|
108
117
|
*/
|
|
109
|
-
function
|
|
110
|
-
fields: Record<string, string>,
|
|
111
|
-
): { foreignKey: { map: string } } | undefined {
|
|
118
|
+
function userRelationshipDb(fields: Record<string, string>): NonNullable<RelationshipField['db']> {
|
|
112
119
|
const column = fields.userId
|
|
113
|
-
return
|
|
120
|
+
return {
|
|
121
|
+
...(column ? { foreignKey: { map: column } } : {}),
|
|
122
|
+
extendPrismaSchema: ({ fkLine, relationLine }) => ({
|
|
123
|
+
fkLine,
|
|
124
|
+
relationLine: relationLine.replace('@relation(', '@relation(onDelete: Cascade, '),
|
|
125
|
+
}),
|
|
126
|
+
}
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
/**
|
|
117
130
|
* Create the Auth user list, applying derived field column maps + table map and
|
|
118
131
|
* wiring the session/account relationships to the derived keys.
|
|
132
|
+
*
|
|
133
|
+
* Per ADR-0013, the plugin ships no permissive access default: the list is
|
|
134
|
+
* closed unless the application supplies access via `extendUserList.access`
|
|
135
|
+
* (takes precedence — it predates the keyed `access` passthrough and is
|
|
136
|
+
* User-specific) or the `access.user` passthrough.
|
|
119
137
|
*/
|
|
120
138
|
function createUserList(
|
|
121
139
|
model: NormalizedAuthModelConfig,
|
|
122
140
|
keys: DerivedAuthLists['keys'],
|
|
123
141
|
userConfig: ExtendUserListConfig,
|
|
142
|
+
access: AuthAccessConfig['user'],
|
|
124
143
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
125
144
|
): ListConfig<any> {
|
|
126
145
|
const f = model.fields
|
|
@@ -143,34 +162,21 @@ function createUserList(
|
|
|
143
162
|
...(userConfig.fields || {}),
|
|
144
163
|
},
|
|
145
164
|
db: listDb(model, DEFAULT_MODEL_NAMES.user),
|
|
146
|
-
access: userConfig.access ||
|
|
147
|
-
operation: {
|
|
148
|
-
query: () => true,
|
|
149
|
-
create: () => true,
|
|
150
|
-
update: ({ session, item }) => {
|
|
151
|
-
if (!session) return false
|
|
152
|
-
const userId = (session as { userId?: string }).userId
|
|
153
|
-
const itemId = (item as { id?: string })?.id
|
|
154
|
-
return userId === itemId
|
|
155
|
-
},
|
|
156
|
-
delete: ({ session, item }) => {
|
|
157
|
-
if (!session) return false
|
|
158
|
-
const userId = (session as { userId?: string }).userId
|
|
159
|
-
const itemId = (item as { id?: string })?.id
|
|
160
|
-
return userId === itemId
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
|
-
},
|
|
165
|
+
access: userConfig.access || access,
|
|
164
166
|
hooks: userConfig.hooks,
|
|
165
167
|
})
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
/**
|
|
169
171
|
* Create the Auth session list.
|
|
172
|
+
*
|
|
173
|
+
* Per ADR-0013, the plugin ships no permissive access default — closed unless
|
|
174
|
+
* the application supplies `access.session`.
|
|
170
175
|
*/
|
|
171
176
|
function createSessionList(
|
|
172
177
|
model: NormalizedAuthModelConfig,
|
|
173
178
|
keys: DerivedAuthLists['keys'],
|
|
179
|
+
access: AuthAccessConfig['session'],
|
|
174
180
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
175
181
|
): ListConfig<any> {
|
|
176
182
|
const f = model.fields
|
|
@@ -186,39 +192,25 @@ function createSessionList(
|
|
|
186
192
|
userAgent: text({ db: fieldDb('userAgent', f) }),
|
|
187
193
|
user: relationship({
|
|
188
194
|
ref: `${keys.user}.sessions`,
|
|
189
|
-
|
|
195
|
+
isIndexed: false,
|
|
196
|
+
db: userRelationshipDb(f),
|
|
190
197
|
}),
|
|
191
198
|
},
|
|
192
199
|
db: listDb(model, DEFAULT_MODEL_NAMES.session),
|
|
193
|
-
access
|
|
194
|
-
operation: {
|
|
195
|
-
query: ({ session }) => {
|
|
196
|
-
if (!session) return false
|
|
197
|
-
const userId = (session as { userId?: string }).userId
|
|
198
|
-
if (!userId) return false
|
|
199
|
-
return {
|
|
200
|
-
user: { id: { equals: userId } },
|
|
201
|
-
} as Record<string, unknown>
|
|
202
|
-
},
|
|
203
|
-
create: () => true,
|
|
204
|
-
update: () => false,
|
|
205
|
-
delete: ({ session, item }) => {
|
|
206
|
-
if (!session) return false
|
|
207
|
-
const userId = (session as { userId?: string }).userId
|
|
208
|
-
const itemUserId = (item as { user?: { id?: string } })?.user?.id
|
|
209
|
-
return userId === itemUserId
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
},
|
|
200
|
+
access,
|
|
213
201
|
})
|
|
214
202
|
}
|
|
215
203
|
|
|
216
204
|
/**
|
|
217
205
|
* Create the Auth account list.
|
|
206
|
+
*
|
|
207
|
+
* Per ADR-0013, the plugin ships no permissive access default — closed unless
|
|
208
|
+
* the application supplies `access.account`.
|
|
218
209
|
*/
|
|
219
210
|
function createAccountList(
|
|
220
211
|
model: NormalizedAuthModelConfig,
|
|
221
212
|
keys: DerivedAuthLists['keys'],
|
|
213
|
+
access: AuthAccessConfig['account'],
|
|
222
214
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
223
215
|
): ListConfig<any> {
|
|
224
216
|
const f = model.fields
|
|
@@ -228,7 +220,8 @@ function createAccountList(
|
|
|
228
220
|
providerId: text({ validation: { isRequired: true }, db: fieldDb('providerId', f) }),
|
|
229
221
|
user: relationship({
|
|
230
222
|
ref: `${keys.user}.accounts`,
|
|
231
|
-
|
|
223
|
+
isIndexed: false,
|
|
224
|
+
db: userRelationshipDb(f),
|
|
232
225
|
}),
|
|
233
226
|
accessToken: text({ db: fieldDb('accessToken', f) }),
|
|
234
227
|
refreshToken: text({ db: fieldDb('refreshToken', f) }),
|
|
@@ -239,39 +232,19 @@ function createAccountList(
|
|
|
239
232
|
password: text({ db: fieldDb('password', f) }),
|
|
240
233
|
},
|
|
241
234
|
db: listDb(model, DEFAULT_MODEL_NAMES.account),
|
|
242
|
-
access
|
|
243
|
-
operation: {
|
|
244
|
-
query: ({ session }) => {
|
|
245
|
-
if (!session) return false
|
|
246
|
-
const userId = (session as { userId?: string }).userId
|
|
247
|
-
if (!userId) return false
|
|
248
|
-
return {
|
|
249
|
-
user: { id: { equals: userId } },
|
|
250
|
-
} as Record<string, unknown>
|
|
251
|
-
},
|
|
252
|
-
create: () => true,
|
|
253
|
-
update: ({ session, item }) => {
|
|
254
|
-
if (!session) return false
|
|
255
|
-
const userId = (session as { userId?: string }).userId
|
|
256
|
-
const itemUserId = (item as { user?: { id?: string } })?.user?.id
|
|
257
|
-
return userId === itemUserId
|
|
258
|
-
},
|
|
259
|
-
delete: ({ session, item }) => {
|
|
260
|
-
if (!session) return false
|
|
261
|
-
const userId = (session as { userId?: string }).userId
|
|
262
|
-
const itemUserId = (item as { user?: { id?: string } })?.user?.id
|
|
263
|
-
return userId === itemUserId
|
|
264
|
-
},
|
|
265
|
-
},
|
|
266
|
-
},
|
|
235
|
+
access,
|
|
267
236
|
})
|
|
268
237
|
}
|
|
269
238
|
|
|
270
239
|
/**
|
|
271
240
|
* Create the Auth verification list.
|
|
241
|
+
*
|
|
242
|
+
* Per ADR-0013, the plugin ships no permissive access default — closed unless
|
|
243
|
+
* the application supplies `access.verification`.
|
|
272
244
|
*/
|
|
273
245
|
function createVerificationList(
|
|
274
246
|
model: NormalizedAuthModelConfig,
|
|
247
|
+
access: AuthAccessConfig['verification'],
|
|
275
248
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
276
249
|
): ListConfig<any> {
|
|
277
250
|
const f = model.fields
|
|
@@ -282,27 +255,28 @@ function createVerificationList(
|
|
|
282
255
|
expiresAt: timestamp({ db: fieldDb('expiresAt', f) }),
|
|
283
256
|
},
|
|
284
257
|
db: listDb(model, DEFAULT_MODEL_NAMES.verification),
|
|
285
|
-
access
|
|
286
|
-
operation: {
|
|
287
|
-
query: () => false,
|
|
288
|
-
create: () => true,
|
|
289
|
-
update: () => false,
|
|
290
|
-
delete: () => true,
|
|
291
|
-
},
|
|
292
|
-
},
|
|
258
|
+
access,
|
|
293
259
|
})
|
|
294
260
|
}
|
|
295
261
|
|
|
296
262
|
/**
|
|
297
263
|
* Derive the OpenSaaS Auth lists from the resolved better-auth model config.
|
|
298
264
|
*
|
|
265
|
+
* Per ADR-0013 the derived lists ship **closed** (no permissive operation
|
|
266
|
+
* access) unless the application supplies access via `accessConfig` (the
|
|
267
|
+
* `authPlugin({ access: … })` passthrough, keyed by better-auth model name) or,
|
|
268
|
+
* for the user list specifically, `userConfig.access` (`extendUserList.access`,
|
|
269
|
+
* which takes precedence — see {@link AuthAccessConfig}).
|
|
270
|
+
*
|
|
299
271
|
* @param models - Resolved better-auth per-model config (modelName + field column maps)
|
|
300
272
|
* @param userConfig - Extra User-list fields/access/hooks supplied via `extendUserList`
|
|
273
|
+
* @param accessConfig - App-authored access for each Auth list, keyed by better-auth model name
|
|
301
274
|
* @returns The derived list keys and the four Auth list configs keyed by those keys
|
|
302
275
|
*/
|
|
303
276
|
export function deriveAuthLists(
|
|
304
277
|
models: NormalizedAuthModels,
|
|
305
278
|
userConfig: ExtendUserListConfig = {},
|
|
279
|
+
accessConfig: AuthAccessConfig = {},
|
|
306
280
|
): DerivedAuthLists {
|
|
307
281
|
const keys = {
|
|
308
282
|
user: models.user.modelName,
|
|
@@ -313,10 +287,10 @@ export function deriveAuthLists(
|
|
|
313
287
|
|
|
314
288
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
315
289
|
const lists: Record<string, ListConfig<any>> = {
|
|
316
|
-
[keys.user]: createUserList(models.user, keys, userConfig),
|
|
317
|
-
[keys.session]: createSessionList(models.session, keys),
|
|
318
|
-
[keys.account]: createAccountList(models.account, keys),
|
|
319
|
-
[keys.verification]: createVerificationList(models.verification),
|
|
290
|
+
[keys.user]: createUserList(models.user, keys, userConfig, accessConfig.user),
|
|
291
|
+
[keys.session]: createSessionList(models.session, keys, accessConfig.session),
|
|
292
|
+
[keys.account]: createAccountList(models.account, keys, accessConfig.account),
|
|
293
|
+
[keys.verification]: createVerificationList(models.verification, accessConfig.verification),
|
|
320
294
|
}
|
|
321
295
|
|
|
322
296
|
return { keys, lists }
|
package/src/config/index.ts
CHANGED
|
@@ -113,6 +113,7 @@ export function normalizeAuthConfig(config: AuthConfig): NormalizedAuthConfig {
|
|
|
113
113
|
schema: config.schema,
|
|
114
114
|
sessionFields,
|
|
115
115
|
extendUserList: config.extendUserList || {},
|
|
116
|
+
access: config.access || {},
|
|
116
117
|
sendEmail:
|
|
117
118
|
config.sendEmail ||
|
|
118
119
|
(async ({ to, subject, html }) => {
|
package/src/config/plugin.ts
CHANGED
|
@@ -44,23 +44,41 @@ export function authPlugin(config: AuthConfig): Plugin {
|
|
|
44
44
|
// User/Session/Account/Verification keys; with overrides (e.g.
|
|
45
45
|
// user.modelName: 'AuthUser') the lists are keyed and column-mapped to
|
|
46
46
|
// match the developer's live better-auth tables.
|
|
47
|
-
const authLists = getAuthLists(
|
|
47
|
+
const authLists = getAuthLists(
|
|
48
|
+
normalized.extendUserList,
|
|
49
|
+
normalized.models,
|
|
50
|
+
normalized.access,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// The same base-model list keys the Auth lists above were derived under
|
|
54
|
+
// (e.g. `user.modelName: 'AuthUser'`). A provider plugin's schema
|
|
55
|
+
// extension of a base model (e.g. `user`) must resolve against this
|
|
56
|
+
// remap too, so it lands on the adopted Auth list rather than a
|
|
57
|
+
// re-derived key that can collide with an unrelated host list.
|
|
58
|
+
const baseModelKeys = {
|
|
59
|
+
user: normalized.models.user.modelName,
|
|
60
|
+
session: normalized.models.session.modelName,
|
|
61
|
+
account: normalized.models.account.modelName,
|
|
62
|
+
verification: normalized.models.verification.modelName,
|
|
63
|
+
}
|
|
48
64
|
|
|
49
65
|
// Extract additional lists from Better Auth plugins
|
|
50
66
|
for (const plugin of normalized.betterAuthPlugins) {
|
|
51
67
|
if (plugin && typeof plugin === 'object' && 'schema' in plugin) {
|
|
52
68
|
// Plugin has schema property - convert to OpenSaaS lists
|
|
53
69
|
const pluginSchema = plugin.schema
|
|
54
|
-
const pluginLists = convertBetterAuthSchema(pluginSchema)
|
|
70
|
+
const pluginLists = convertBetterAuthSchema(pluginSchema, baseModelKeys)
|
|
55
71
|
|
|
56
72
|
// Add or extend lists from plugin
|
|
57
73
|
for (const [listName, listConfig] of Object.entries(pluginLists)) {
|
|
58
74
|
if (context.config.lists[listName]) {
|
|
59
|
-
// List exists
|
|
75
|
+
// List already exists — merge fields/hooks/mcp in only. Access
|
|
76
|
+
// control belongs to whoever owns the list; per ADR-0013 an
|
|
77
|
+
// extension must never carry operation-level access for a
|
|
78
|
+
// pre-existing list (the plugin engine throws if it does).
|
|
60
79
|
context.extendList(listName, {
|
|
61
80
|
fields: listConfig.fields,
|
|
62
81
|
hooks: listConfig.hooks,
|
|
63
|
-
access: listConfig.access,
|
|
64
82
|
mcp: listConfig.mcp,
|
|
65
83
|
})
|
|
66
84
|
} else {
|
|
@@ -82,11 +100,13 @@ export function authPlugin(config: AuthConfig): Plugin {
|
|
|
82
100
|
// "merge auth fields into my User" behaviour.
|
|
83
101
|
for (const [listName, listConfig] of Object.entries(authLists)) {
|
|
84
102
|
if (context.config.lists[listName]) {
|
|
85
|
-
// A list already exists under this derived key — merge auth fields
|
|
103
|
+
// A list already exists under this derived key — merge auth fields
|
|
104
|
+
// in only. Access control belongs to whoever owns the list (the
|
|
105
|
+
// application declared it first), so the plugin never forwards its
|
|
106
|
+
// own access here — see ADR-0013.
|
|
86
107
|
context.extendList(listName, {
|
|
87
108
|
fields: listConfig.fields,
|
|
88
109
|
hooks: listConfig.hooks,
|
|
89
|
-
access: listConfig.access,
|
|
90
110
|
mcp: listConfig.mcp,
|
|
91
111
|
})
|
|
92
112
|
} else {
|
|
@@ -143,7 +163,7 @@ export function authPlugin(config: AuthConfig): Plugin {
|
|
|
143
163
|
}
|
|
144
164
|
},
|
|
145
165
|
|
|
146
|
-
runtime: (context) => {
|
|
166
|
+
runtime: (context, sudo) => {
|
|
147
167
|
// Resolve the user list's context.db key from the configured user model.
|
|
148
168
|
// context.db is keyed camelCase, so 'User' -> 'user', 'AuthUser' -> 'authUser'.
|
|
149
169
|
const userDbKey = getDbKey(normalized.models.user.modelName)
|
|
@@ -151,24 +171,27 @@ export function authPlugin(config: AuthConfig): Plugin {
|
|
|
151
171
|
// Provide auth-related utilities at runtime
|
|
152
172
|
return {
|
|
153
173
|
/**
|
|
154
|
-
* Get user by ID
|
|
155
|
-
*
|
|
174
|
+
* Get user by ID.
|
|
175
|
+
*
|
|
176
|
+
* Resolves through `sudo()` (per ADR-0013): the User list ships closed
|
|
177
|
+
* by default, and "who is this session" must not depend on the
|
|
178
|
+
* application's User access policy.
|
|
156
179
|
*/
|
|
157
180
|
getUser: async (userId: string) => {
|
|
158
|
-
return await
|
|
181
|
+
return await sudo().db[userDbKey].findUnique({
|
|
159
182
|
where: { id: userId },
|
|
160
183
|
})
|
|
161
184
|
},
|
|
162
185
|
|
|
163
186
|
/**
|
|
164
|
-
* Get current user from session
|
|
165
|
-
*
|
|
187
|
+
* Get current user from session. Extracts userId from session and
|
|
188
|
+
* fetches user data via `sudo()` — see {@link getUser}.
|
|
166
189
|
*/
|
|
167
190
|
getCurrentUser: async () => {
|
|
168
191
|
if (!context.session?.userId) {
|
|
169
192
|
return null
|
|
170
193
|
}
|
|
171
|
-
return await
|
|
194
|
+
return await sudo().db[userDbKey].findUnique({
|
|
172
195
|
where: { id: context.session.userId },
|
|
173
196
|
})
|
|
174
197
|
},
|
package/src/config/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ListConfig } from '@opensaas/stack-core'
|
|
1
2
|
import type { ExtendUserListConfig } from '../lists/index.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -100,6 +101,52 @@ export type SessionConfig = {
|
|
|
100
101
|
* })
|
|
101
102
|
* ```
|
|
102
103
|
*/
|
|
104
|
+
/**
|
|
105
|
+
* App-authored operation + field-level access control for the Auth lists,
|
|
106
|
+
* keyed by better-auth model name (not by the derived list key, so it stays
|
|
107
|
+
* remap-proof when e.g. `user.modelName: 'AuthUser'`).
|
|
108
|
+
*
|
|
109
|
+
* Per ADR-0013, the auth plugin ships its created lists (User/Session/
|
|
110
|
+
* Account/Verification) **closed** — no permissive defaults. This is the
|
|
111
|
+
* application's seam to grant them access: the plugin applies each entry to
|
|
112
|
+
* the corresponding list when it creates it (its own `addList` path), so the
|
|
113
|
+
* access rides along with the list's `@@map`/`@@schema`/fields and can't
|
|
114
|
+
* drift from the plugin's shape. A model with no entry here stays closed
|
|
115
|
+
* (deny-by-default).
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* authPlugin({
|
|
120
|
+
* access: {
|
|
121
|
+
* // Signed-in users can read the directory; only self can write.
|
|
122
|
+
* user: {
|
|
123
|
+
* operation: {
|
|
124
|
+
* query: ({ session }) => !!session,
|
|
125
|
+
* update: ({ session, item }) => session?.userId === item.id,
|
|
126
|
+
* },
|
|
127
|
+
* },
|
|
128
|
+
* // A user can read only their own sessions.
|
|
129
|
+
* session: {
|
|
130
|
+
* operation: {
|
|
131
|
+
* query: ({ session }) =>
|
|
132
|
+
* session ? { user: { id: { equals: session.userId } } } : false,
|
|
133
|
+
* },
|
|
134
|
+
* },
|
|
135
|
+
* },
|
|
136
|
+
* })
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export type AuthAccessConfig = {
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
141
|
+
user?: ListConfig<any>['access']
|
|
142
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
143
|
+
session?: ListConfig<any>['access']
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
145
|
+
account?: ListConfig<any>['access']
|
|
146
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
147
|
+
verification?: ListConfig<any>['access']
|
|
148
|
+
}
|
|
149
|
+
|
|
103
150
|
export type AuthModelConfig = {
|
|
104
151
|
/**
|
|
105
152
|
* The table/list name for this model.
|
|
@@ -239,6 +286,23 @@ export type AuthConfig = {
|
|
|
239
286
|
*/
|
|
240
287
|
extendUserList?: ExtendUserListConfig
|
|
241
288
|
|
|
289
|
+
/**
|
|
290
|
+
* App-authored access control for the Auth lists (User/Session/Account/
|
|
291
|
+
* Verification), keyed by better-auth model name. See {@link AuthAccessConfig}.
|
|
292
|
+
*
|
|
293
|
+
* Per ADR-0013 the auth plugin ships these lists **closed** by default — a
|
|
294
|
+
* model with no entry here denies every operation (`context.db` reads/writes
|
|
295
|
+
* return `null`/`[]` and the list doesn't surface in the admin UI). Grant
|
|
296
|
+
* access explicitly for any Auth list your application reads or writes
|
|
297
|
+
* through `context.db`.
|
|
298
|
+
*
|
|
299
|
+
* For the `user` model specifically, {@link ExtendUserListConfig.access}
|
|
300
|
+
* (via `extendUserList.access`) is still honoured and takes precedence over
|
|
301
|
+
* `access.user` if both are set — it predates this option and remains the
|
|
302
|
+
* narrower, User-specific override.
|
|
303
|
+
*/
|
|
304
|
+
access?: AuthAccessConfig
|
|
305
|
+
|
|
242
306
|
/**
|
|
243
307
|
* Custom email sending function for verification and password reset
|
|
244
308
|
* If not provided, emails will be logged to console
|
package/src/lists/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
|
|
2
|
-
import type { NormalizedAuthModels } from '../config/types.js'
|
|
2
|
+
import type { AuthAccessConfig, NormalizedAuthModels } from '../config/types.js'
|
|
3
3
|
import { deriveAuthLists } from '../config/derive-auth-lists.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -12,8 +12,12 @@ export type ExtendUserListConfig = {
|
|
|
12
12
|
*/
|
|
13
13
|
fields?: Record<string, FieldConfig>
|
|
14
14
|
/**
|
|
15
|
-
* Access control for the User list
|
|
16
|
-
*
|
|
15
|
+
* Access control for the User list.
|
|
16
|
+
*
|
|
17
|
+
* Per ADR-0013, if neither this nor `authPlugin({ access: { user: … } })` is
|
|
18
|
+
* provided, the User list is **closed** (deny-by-default) — it no longer
|
|
19
|
+
* defaults to permissive access. Takes precedence over `access.user` when
|
|
20
|
+
* both are set.
|
|
17
21
|
*/
|
|
18
22
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
19
23
|
access?: ListConfig<any>['access']
|
|
@@ -83,11 +87,13 @@ export function createVerificationList(): ListConfig<any> {
|
|
|
83
87
|
*
|
|
84
88
|
* @param userConfig - Extra User-list fields/access/hooks (from `extendUserList`)
|
|
85
89
|
* @param models - Resolved better-auth model config; defaults to the better-auth defaults
|
|
90
|
+
* @param accessConfig - App-authored access for each Auth list, keyed by better-auth model name
|
|
86
91
|
*/
|
|
87
92
|
export function getAuthLists(
|
|
88
93
|
userConfig?: ExtendUserListConfig,
|
|
89
94
|
models: NormalizedAuthModels = DEFAULT_MODELS,
|
|
95
|
+
accessConfig?: AuthAccessConfig,
|
|
90
96
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
91
97
|
): Record<string, ListConfig<any>> {
|
|
92
|
-
return deriveAuthLists(models, userConfig || {}).lists
|
|
98
|
+
return deriveAuthLists(models, userConfig || {}, accessConfig || {}).lists
|
|
93
99
|
}
|
package/src/runtime/types.ts
CHANGED
|
@@ -9,17 +9,19 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export interface AuthRuntimeServices {
|
|
11
11
|
/**
|
|
12
|
-
* Get user by ID
|
|
13
|
-
*
|
|
12
|
+
* Get user by ID.
|
|
13
|
+
* Resolves through the plugin runtime's `sudo` helper, so the result does
|
|
14
|
+
* not depend on the application's User list access policy (ADR-0013).
|
|
14
15
|
*
|
|
15
16
|
* @param userId - The ID of the user to fetch
|
|
16
|
-
* @returns User object or null if not found
|
|
17
|
+
* @returns User object or null if not found
|
|
17
18
|
*/
|
|
18
19
|
getUser: (userId: string) => Promise<unknown>
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
* Get current user from session
|
|
22
|
-
* Extracts userId from session and fetches user data
|
|
22
|
+
* Get current user from session.
|
|
23
|
+
* Extracts userId from session and fetches user data through the plugin
|
|
24
|
+
* runtime's `sudo` helper — see {@link getUser}.
|
|
23
25
|
*
|
|
24
26
|
* @returns Current user object or null if not authenticated or not found
|
|
25
27
|
*/
|