@opensaas/stack-auth 0.37.0 → 0.39.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 +60 -0
- package/CLAUDE.md +50 -3
- package/dist/config/adopt-better-auth-tables.d.ts +23 -3
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +7 -2
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +6 -1
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +63 -16
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +12 -5
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +7 -2
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +62 -7
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server/build-better-auth-options.test.d.ts +2 -0
- package/dist/server/build-better-auth-options.test.d.ts.map +1 -0
- package/dist/server/build-better-auth-options.test.js +29 -0
- package/dist/server/build-better-auth-options.test.js.map +1 -0
- package/dist/server/get-session-from-auth.test.d.ts +2 -0
- package/dist/server/get-session-from-auth.test.d.ts.map +1 -0
- package/dist/server/get-session-from-auth.test.js +25 -0
- package/dist/server/get-session-from-auth.test.js.map +1 -0
- package/dist/server/index.d.ts +108 -15
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +151 -63
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema-converter.d.ts +15 -6
- package/dist/server/schema-converter.d.ts.map +1 -1
- package/dist/server/schema-converter.js +14 -2
- package/dist/server/schema-converter.js.map +1 -1
- package/package.json +5 -5
- package/src/config/adopt-better-auth-tables.ts +31 -4
- package/src/config/derive-auth-lists.ts +82 -21
- package/src/config/index.ts +18 -5
- package/src/config/plugin.ts +7 -2
- package/src/config/types.ts +63 -9
- package/src/server/build-better-auth-options.test.ts +59 -0
- package/src/server/get-session-from-auth.test.ts +52 -0
- package/src/server/index.ts +273 -41
- package/src/server/schema-converter.ts +29 -8
- package/tests/adopt-better-auth-tables.test.ts +73 -0
- package/tests/config.test.ts +161 -0
- package/tests/derive-auth-lists.test.ts +104 -0
- package/tests/generated-fk-shape.test.ts +81 -0
- package/tests/plugin-schema-placement.test.ts +39 -0
- package/tests/rate-limit-e2e.test.ts +239 -0
- package/tests/schema-converter.test.ts +58 -0
- package/tests/server.test.ts +310 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +7 -1
|
@@ -27,7 +27,14 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { list } from '@opensaas/stack-core'
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
text,
|
|
32
|
+
timestamp,
|
|
33
|
+
checkbox,
|
|
34
|
+
integer,
|
|
35
|
+
bigInt,
|
|
36
|
+
relationship,
|
|
37
|
+
} from '@opensaas/stack-core/fields'
|
|
31
38
|
import type { ListConfig } from '@opensaas/stack-core'
|
|
32
39
|
import type { RelationshipField } from '@opensaas/stack-core/fields'
|
|
33
40
|
import type { ExtendUserListConfig } from '../lists/index.js'
|
|
@@ -45,6 +52,8 @@ export type DerivedAuthLists = {
|
|
|
45
52
|
session: string
|
|
46
53
|
account: string
|
|
47
54
|
verification: string
|
|
55
|
+
/** Only present when a `RateLimit` list was derived (`rateLimit.storage === 'database'`). */
|
|
56
|
+
rateLimit?: string
|
|
48
57
|
}
|
|
49
58
|
/** The derived list configs, keyed by their derived list keys. */
|
|
50
59
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -55,12 +64,12 @@ export type DerivedAuthLists = {
|
|
|
55
64
|
* Build the list-level `db` config (`timestamps` + `@@map` + `@@schema`) for a
|
|
56
65
|
* derived list.
|
|
57
66
|
*
|
|
58
|
-
*
|
|
59
|
-
* adapter writes `createdAt`/`updatedAt` on every
|
|
60
|
-
* converter returns `null` for those columns
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* better-auth
|
|
67
|
+
* `timestamps` is a per-model input rather than hardcoded: better-auth's
|
|
68
|
+
* adapter writes `createdAt`/`updatedAt` on every user/session/account/
|
|
69
|
+
* verification row (and the schema converter returns `null` for those columns,
|
|
70
|
+
* assuming the generator injects them), so those four opt back into
|
|
71
|
+
* auto-timestamps now that they're OFF by default (ADR-0004). The `RateLimit`
|
|
72
|
+
* model has neither column in better-auth's own schema, so it passes `false`.
|
|
64
73
|
*
|
|
65
74
|
* The physical table name (`@@map`) comes from the model's resolved
|
|
66
75
|
* `tableName` — independent of the list key/`modelName` — so a renamed list
|
|
@@ -68,17 +77,17 @@ export type DerivedAuthLists = {
|
|
|
68
77
|
* default lowercase table names). When a `schema` is configured (plugin-level
|
|
69
78
|
* or per-model), the list is placed in that Postgres schema via `@@schema(...)`.
|
|
70
79
|
*
|
|
71
|
-
* With no `tableName`/`schema` overrides
|
|
72
|
-
* leaving the default `User`/`Session`/...
|
|
80
|
+
* With no `tableName`/`schema` overrides and `timestamps: true` we emit only
|
|
81
|
+
* `{ timestamps: true }`, leaving the default `User`/`Session`/... output
|
|
82
|
+
* unchanged.
|
|
73
83
|
*/
|
|
74
|
-
function listDb(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
} {
|
|
84
|
+
function listDb(
|
|
85
|
+
model: NormalizedAuthModelConfig,
|
|
86
|
+
timestamps: boolean,
|
|
87
|
+
): { timestamps?: true; map?: string; schema?: string } {
|
|
79
88
|
const schema = model.schema
|
|
80
89
|
return {
|
|
81
|
-
timestamps: true,
|
|
90
|
+
...(timestamps ? { timestamps: true as const } : {}),
|
|
82
91
|
...(model.tableName !== undefined ? { map: model.tableName } : {}),
|
|
83
92
|
...(schema !== undefined ? { schema } : {}),
|
|
84
93
|
}
|
|
@@ -156,7 +165,7 @@ function createUserList(
|
|
|
156
165
|
// Custom fields from user config
|
|
157
166
|
...(userConfig.fields || {}),
|
|
158
167
|
},
|
|
159
|
-
db: listDb(model),
|
|
168
|
+
db: listDb(model, true),
|
|
160
169
|
access: userConfig.access || access,
|
|
161
170
|
hooks: userConfig.hooks,
|
|
162
171
|
})
|
|
@@ -193,7 +202,7 @@ function createSessionList(
|
|
|
193
202
|
db: userRelationshipDb(f),
|
|
194
203
|
}),
|
|
195
204
|
},
|
|
196
|
-
db: listDb(model),
|
|
205
|
+
db: listDb(model, true),
|
|
197
206
|
access,
|
|
198
207
|
})
|
|
199
208
|
}
|
|
@@ -228,7 +237,7 @@ function createAccountList(
|
|
|
228
237
|
idToken: text({ db: fieldDb('idToken', f) }),
|
|
229
238
|
password: text({ db: fieldDb('password', f) }),
|
|
230
239
|
},
|
|
231
|
-
db: listDb(model),
|
|
240
|
+
db: listDb(model, true),
|
|
232
241
|
access,
|
|
233
242
|
})
|
|
234
243
|
}
|
|
@@ -253,7 +262,51 @@ function createVerificationList(
|
|
|
253
262
|
db: { isNullable: false, ...fieldDb('expiresAt', f) },
|
|
254
263
|
}),
|
|
255
264
|
},
|
|
256
|
-
db: listDb(model),
|
|
265
|
+
db: listDb(model, true),
|
|
266
|
+
access,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Create the Auth rate-limit list, present only when `rateLimit.storage ===
|
|
272
|
+
* 'database'` derives a `rateLimit` model. Mirrors better-auth's own
|
|
273
|
+
* `rateLimit` table exactly (`getAuthTables` in `@better-auth/core`, verified
|
|
274
|
+
* against better-auth@1.6.25): `key` (unique — load-bearing, not cosmetic,
|
|
275
|
+
* since the limiter races concurrent requests into a create and relies on the
|
|
276
|
+
* resulting constraint violation to serialise them), `count`, and
|
|
277
|
+
* `lastRequest` (a millisecond epoch, hence `bigInt()` rather than
|
|
278
|
+
* `integer()`, which would overflow). None of the three columns carries a
|
|
279
|
+
* Prisma default — the limiter supplies `lastRequest` explicitly on every
|
|
280
|
+
* create/update, and an adopted live table has no default to match. No
|
|
281
|
+
* `createdAt`/`updatedAt` either (see `listDb`'s `timestamps` argument):
|
|
282
|
+
* better-auth's own rate-limit table has neither column.
|
|
283
|
+
*
|
|
284
|
+
* Per ADR-0013, the plugin ships no permissive access default — closed unless
|
|
285
|
+
* the application supplies `access.rateLimit`.
|
|
286
|
+
*/
|
|
287
|
+
function createRateLimitList(
|
|
288
|
+
model: NormalizedAuthModelConfig,
|
|
289
|
+
access: AuthAccessConfig['rateLimit'],
|
|
290
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
291
|
+
): ListConfig<any> {
|
|
292
|
+
const f = model.fields
|
|
293
|
+
return list({
|
|
294
|
+
fields: {
|
|
295
|
+
key: text({
|
|
296
|
+
validation: { isRequired: true },
|
|
297
|
+
isIndexed: 'unique',
|
|
298
|
+
db: fieldDb('key', f),
|
|
299
|
+
}),
|
|
300
|
+
count: integer({
|
|
301
|
+
validation: { isRequired: true },
|
|
302
|
+
db: { isNullable: false, ...fieldDb('count', f) },
|
|
303
|
+
}),
|
|
304
|
+
lastRequest: bigInt({
|
|
305
|
+
validation: { isRequired: true },
|
|
306
|
+
db: { isNullable: false, ...fieldDb('lastRequest', f) },
|
|
307
|
+
}),
|
|
308
|
+
},
|
|
309
|
+
db: listDb(model, false),
|
|
257
310
|
access,
|
|
258
311
|
})
|
|
259
312
|
}
|
|
@@ -267,21 +320,25 @@ function createVerificationList(
|
|
|
267
320
|
* for the user list specifically, `userConfig.access` (`extendUserList.access`,
|
|
268
321
|
* which takes precedence — see {@link AuthAccessConfig}).
|
|
269
322
|
*
|
|
323
|
+
* A fifth `RateLimit` list is included only when `models.rateLimit` is
|
|
324
|
+
* present (i.e. `rateLimit.storage === 'database'`).
|
|
325
|
+
*
|
|
270
326
|
* @param models - Resolved better-auth per-model config (modelName + field column maps)
|
|
271
327
|
* @param userConfig - Extra User-list fields/access/hooks supplied via `extendUserList`
|
|
272
328
|
* @param accessConfig - App-authored access for each Auth list, keyed by better-auth model name
|
|
273
|
-
* @returns The derived list keys and the
|
|
329
|
+
* @returns The derived list keys and the Auth list configs keyed by those keys
|
|
274
330
|
*/
|
|
275
331
|
export function deriveAuthLists(
|
|
276
332
|
models: NormalizedAuthModels,
|
|
277
333
|
userConfig: ExtendUserListConfig = {},
|
|
278
334
|
accessConfig: AuthAccessConfig = {},
|
|
279
335
|
): DerivedAuthLists {
|
|
280
|
-
const keys = {
|
|
336
|
+
const keys: DerivedAuthLists['keys'] = {
|
|
281
337
|
user: models.user.modelName,
|
|
282
338
|
session: models.session.modelName,
|
|
283
339
|
account: models.account.modelName,
|
|
284
340
|
verification: models.verification.modelName,
|
|
341
|
+
...(models.rateLimit ? { rateLimit: models.rateLimit.modelName } : {}),
|
|
285
342
|
}
|
|
286
343
|
|
|
287
344
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
@@ -292,5 +349,9 @@ export function deriveAuthLists(
|
|
|
292
349
|
[keys.verification]: createVerificationList(models.verification, accessConfig.verification),
|
|
293
350
|
}
|
|
294
351
|
|
|
352
|
+
if (models.rateLimit && keys.rateLimit) {
|
|
353
|
+
lists[keys.rateLimit] = createRateLimitList(models.rateLimit, accessConfig.rateLimit)
|
|
354
|
+
}
|
|
355
|
+
|
|
295
356
|
return { keys, lists }
|
|
296
357
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -13,13 +13,15 @@ import type {
|
|
|
13
13
|
/**
|
|
14
14
|
* Default better-auth model names. Used when the developer does not override
|
|
15
15
|
* `modelName`, preserving the historical `User`/`Session`/`Account`/`Verification`
|
|
16
|
-
* keys exactly.
|
|
16
|
+
* keys exactly. `rateLimit` only applies when `rateLimit.storage: 'database'`
|
|
17
|
+
* derives the fifth Auth list.
|
|
17
18
|
*/
|
|
18
19
|
const DEFAULT_MODEL_NAMES = {
|
|
19
20
|
user: 'User',
|
|
20
21
|
session: 'Session',
|
|
21
22
|
account: 'Account',
|
|
22
23
|
verification: 'Verification',
|
|
24
|
+
rateLimit: 'RateLimit',
|
|
23
25
|
} as const
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -51,13 +53,14 @@ function normalizeModelConfig(
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
|
-
* Resolve the better-auth model config for all four auth models
|
|
55
|
-
* `
|
|
56
|
-
*
|
|
56
|
+
* Resolve the better-auth model config for all four auth models, plus a fifth
|
|
57
|
+
* `rateLimit` model when `rateLimit.storage: 'database'` requires deriving
|
|
58
|
+
* the `RateLimit` list. `defaultSchema` is the plugin-level `schema` applied
|
|
59
|
+
* to every model unless a per-model `schema` override is given.
|
|
57
60
|
*/
|
|
58
61
|
function normalizeAuthModels(config: AuthConfig): NormalizedAuthModels {
|
|
59
62
|
const defaultSchema = config.schema
|
|
60
|
-
|
|
63
|
+
const models: NormalizedAuthModels = {
|
|
61
64
|
user: normalizeModelConfig(config.user, DEFAULT_MODEL_NAMES.user, defaultSchema),
|
|
62
65
|
session: normalizeModelConfig(config.session, DEFAULT_MODEL_NAMES.session, defaultSchema),
|
|
63
66
|
account: normalizeModelConfig(config.account, DEFAULT_MODEL_NAMES.account, defaultSchema),
|
|
@@ -67,6 +70,16 @@ function normalizeAuthModels(config: AuthConfig): NormalizedAuthModels {
|
|
|
67
70
|
defaultSchema,
|
|
68
71
|
),
|
|
69
72
|
}
|
|
73
|
+
|
|
74
|
+
if (config.rateLimit?.storage === 'database') {
|
|
75
|
+
models.rateLimit = normalizeModelConfig(
|
|
76
|
+
config.rateLimit,
|
|
77
|
+
DEFAULT_MODEL_NAMES.rateLimit,
|
|
78
|
+
defaultSchema,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return models
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
/**
|
package/src/config/plugin.ts
CHANGED
|
@@ -54,12 +54,17 @@ export function authPlugin(config: AuthConfig): Plugin {
|
|
|
54
54
|
// (e.g. `user.modelName: 'AuthUser'`). A provider plugin's schema
|
|
55
55
|
// extension of a base model (e.g. `user`) must resolve against this
|
|
56
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.
|
|
57
|
+
// re-derived key that can collide with an unrelated host list. The
|
|
58
|
+
// `rateLimit` key is only present when `rateLimit.storage: 'database'`
|
|
59
|
+
// derived the fifth Auth list.
|
|
58
60
|
const baseModelKeys = {
|
|
59
61
|
user: normalized.models.user.modelName,
|
|
60
62
|
session: normalized.models.session.modelName,
|
|
61
63
|
account: normalized.models.account.modelName,
|
|
62
64
|
verification: normalized.models.verification.modelName,
|
|
65
|
+
...(normalized.models.rateLimit
|
|
66
|
+
? { rateLimit: normalized.models.rateLimit.modelName }
|
|
67
|
+
: {}),
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
// Add all auth lists FIRST, before any better-auth plugin schema
|
|
@@ -102,7 +107,7 @@ export function authPlugin(config: AuthConfig): Plugin {
|
|
|
102
107
|
// `oauth_application`, `passkey`) still register as new lists via
|
|
103
108
|
// `addList`, same as before.
|
|
104
109
|
for (const plugin of normalized.betterAuthPlugins) {
|
|
105
|
-
if (plugin && typeof plugin === 'object' &&
|
|
110
|
+
if (plugin && typeof plugin === 'object' && plugin.schema) {
|
|
106
111
|
// Plugin has schema property - convert to OpenSaaS lists
|
|
107
112
|
const pluginSchema = plugin.schema
|
|
108
113
|
const pluginLists = convertBetterAuthSchema(pluginSchema, baseModelKeys)
|
package/src/config/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ListConfig } from '@opensaas/stack-core'
|
|
2
|
-
import type { BetterAuthOptions, User } from 'better-auth'
|
|
2
|
+
import type { BetterAuthOptions, BetterAuthPlugin, User } from 'better-auth'
|
|
3
3
|
import type { ExtendUserListConfig } from '../lists/index.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -203,6 +203,14 @@ export type AuthAccessConfig = {
|
|
|
203
203
|
account?: ListConfig<any>['access']
|
|
204
204
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
205
205
|
verification?: ListConfig<any>['access']
|
|
206
|
+
/**
|
|
207
|
+
* Access control for the `RateLimit` list — only meaningful when
|
|
208
|
+
* `rateLimit.storage: 'database'` derives it. Per ADR-0013 the list ships
|
|
209
|
+
* closed like the other four; grant access here (e.g. to inspect throttled
|
|
210
|
+
* keys in the Admin UI).
|
|
211
|
+
*/
|
|
212
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
213
|
+
rateLimit?: ListConfig<any>['access']
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
export type AuthModelConfig = {
|
|
@@ -337,8 +345,21 @@ export type AuthConfig = {
|
|
|
337
345
|
schema?: string
|
|
338
346
|
|
|
339
347
|
/**
|
|
340
|
-
* Which fields to include in the session object
|
|
341
|
-
*
|
|
348
|
+
* Which fields to include in the session object passed to access control
|
|
349
|
+
* functions — a **flattened projection** of the resolved better-auth
|
|
350
|
+
* session, not the session's own shape. `getSessionFromAuth` (the
|
|
351
|
+
* implementation the scaffolded `getSession()` calls) resolves each name
|
|
352
|
+
* against a fixed precedence: a top-level key on the resolved session
|
|
353
|
+
* object, then the `user` object, then the `session` sub-object.
|
|
354
|
+
* `userId` is special-cased to the authenticated user's `id`.
|
|
355
|
+
*
|
|
356
|
+
* A `customSession` better-auth plugin fully replaces the resolved shape
|
|
357
|
+
* (it can nest fields anywhere, e.g. under its own custom key) —
|
|
358
|
+
* reconciling that shape against `sessionFields` is the application's job.
|
|
359
|
+
* A listed name that can't be resolved is omitted and warns once per
|
|
360
|
+
* field per process, naming what was checked, rather than silently
|
|
361
|
+
* becoming `undefined` in an access control function.
|
|
362
|
+
*
|
|
342
363
|
* @default ['userId', 'email', 'name']
|
|
343
364
|
*
|
|
344
365
|
* @example
|
|
@@ -394,13 +415,28 @@ export type AuthConfig = {
|
|
|
394
415
|
* ]
|
|
395
416
|
* ```
|
|
396
417
|
*/
|
|
397
|
-
|
|
398
|
-
betterAuthPlugins?: any[]
|
|
418
|
+
betterAuthPlugins?: BetterAuthPlugin[]
|
|
399
419
|
|
|
400
420
|
/**
|
|
401
421
|
* Rate limiting configuration
|
|
402
422
|
* Controls rate limiting for authentication endpoints
|
|
403
423
|
*
|
|
424
|
+
* `storage` mirrors better-auth's own `rateLimit.storage` option
|
|
425
|
+
* (`'memory' | 'database' | 'secondary-storage'`, default `'memory'`). Set
|
|
426
|
+
* it to `'database'` to persist the limiter across restarts/instances — the
|
|
427
|
+
* plugin then derives a fifth `RateLimit` Auth list (per ADR-0007) so the
|
|
428
|
+
* required table exists in the generated Prisma schema, following the same
|
|
429
|
+
* adoption knobs (`modelName`/`fields`/`tableName`/`schema`) the other four
|
|
430
|
+
* models carry. Derivation keys off `storage` alone — `enabled: false` with
|
|
431
|
+
* `storage: 'database'` still produces the list, since better-auth still
|
|
432
|
+
* expects the table regardless of whether the limiter is currently active
|
|
433
|
+
* (`enabled` is routinely environment-driven; tying the schema to it would
|
|
434
|
+
* make dev/prod schemas differ).
|
|
435
|
+
*
|
|
436
|
+
* Setting `storage` via `betterAuthOptions.rateLimit.storage` is rejected —
|
|
437
|
+
* use this option instead, since it also has schema consequences the
|
|
438
|
+
* passthrough can't apply.
|
|
439
|
+
*
|
|
404
440
|
* @example
|
|
405
441
|
* ```typescript
|
|
406
442
|
* // Disable rate limiting for testing
|
|
@@ -414,6 +450,12 @@ export type AuthConfig = {
|
|
|
414
450
|
* window: 60, // 60 seconds
|
|
415
451
|
* max: 100, // 100 requests per window
|
|
416
452
|
* }
|
|
453
|
+
*
|
|
454
|
+
* // Persist the limiter in the database (derives a RateLimit list)
|
|
455
|
+
* rateLimit: {
|
|
456
|
+
* enabled: true,
|
|
457
|
+
* storage: 'database',
|
|
458
|
+
* }
|
|
417
459
|
* ```
|
|
418
460
|
*/
|
|
419
461
|
rateLimit?: {
|
|
@@ -428,7 +470,12 @@ export type AuthConfig = {
|
|
|
428
470
|
* @default 100
|
|
429
471
|
*/
|
|
430
472
|
max?: number
|
|
431
|
-
|
|
473
|
+
/**
|
|
474
|
+
* Where better-auth persists the rate limiter.
|
|
475
|
+
* @default 'memory'
|
|
476
|
+
*/
|
|
477
|
+
storage?: 'memory' | 'database' | 'secondary-storage'
|
|
478
|
+
} & AuthModelConfig
|
|
432
479
|
|
|
433
480
|
/**
|
|
434
481
|
* Escape hatch for better-auth options the stack doesn't model — typed as
|
|
@@ -486,7 +533,8 @@ export type NormalizedAuthModelConfig = {
|
|
|
486
533
|
}
|
|
487
534
|
|
|
488
535
|
/**
|
|
489
|
-
* Resolved auth model configuration for all four better-auth models
|
|
536
|
+
* Resolved auth model configuration for all four better-auth models, plus an
|
|
537
|
+
* optional fifth for the database-backed rate limiter.
|
|
490
538
|
* Consumed by the Auth-list derivation and the runtime user-key resolution.
|
|
491
539
|
*/
|
|
492
540
|
export type NormalizedAuthModels = {
|
|
@@ -494,6 +542,12 @@ export type NormalizedAuthModels = {
|
|
|
494
542
|
session: NormalizedAuthModelConfig
|
|
495
543
|
account: NormalizedAuthModelConfig
|
|
496
544
|
verification: NormalizedAuthModelConfig
|
|
545
|
+
/**
|
|
546
|
+
* Present only when `rateLimit.storage === 'database'` — derives a fifth
|
|
547
|
+
* `RateLimit` Auth list. Absent (not `undefined`-valued) otherwise, so
|
|
548
|
+
* `Object.values(models)` never yields a model-less entry.
|
|
549
|
+
*/
|
|
550
|
+
rateLimit?: NormalizedAuthModelConfig
|
|
497
551
|
}
|
|
498
552
|
|
|
499
553
|
/**
|
|
@@ -528,11 +582,11 @@ export type NormalizedAuthConfig = Required<
|
|
|
528
582
|
* default (used to wire the datasource `schemas` array during generation).
|
|
529
583
|
*/
|
|
530
584
|
schema?: string
|
|
531
|
-
|
|
532
|
-
betterAuthPlugins: any[]
|
|
585
|
+
betterAuthPlugins: BetterAuthPlugin[]
|
|
533
586
|
rateLimit?: {
|
|
534
587
|
enabled: boolean
|
|
535
588
|
window?: number
|
|
536
589
|
max?: number
|
|
590
|
+
storage?: 'memory' | 'database' | 'secondary-storage'
|
|
537
591
|
}
|
|
538
592
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest'
|
|
2
|
+
import { betterAuth } from 'better-auth'
|
|
3
|
+
import type { BetterAuthOptions } from 'better-auth'
|
|
4
|
+
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
5
|
+
import { emailOTP, customSession } from 'better-auth/plugins'
|
|
6
|
+
import { buildBetterAuthOptions } from './index.js'
|
|
7
|
+
|
|
8
|
+
// The literal shape a `customSession()` callback replaces the session with —
|
|
9
|
+
// deliberately unlike better-auth's default `{ user, session }`, to prove the
|
|
10
|
+
// builder's return type carries a custom shape through to `api.getSession()`.
|
|
11
|
+
type AppSession = {
|
|
12
|
+
data: { allowAdminUI: boolean; subjectId: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type TestPlugins = [ReturnType<typeof emailOTP>, ReturnType<typeof customSession<AppSession>>]
|
|
16
|
+
|
|
17
|
+
// `ReturnType<typeof buildBetterAuthOptions>` on the overloaded export itself
|
|
18
|
+
// resolves against its last (generic) signature, not the call-site-selected
|
|
19
|
+
// one — wrapping each call shape in its own ordinary function and reading
|
|
20
|
+
// `ReturnType<typeof wrapper>` instead forces TS to resolve overloads exactly
|
|
21
|
+
// as a real call site would.
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
|
|
23
|
+
function callWithNoPlugins(
|
|
24
|
+
config: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
25
|
+
context: AccessContext | Promise<AccessContext>,
|
|
26
|
+
) {
|
|
27
|
+
return buildBetterAuthOptions(config, context)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
|
|
31
|
+
function callWithPlugins(
|
|
32
|
+
config: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
33
|
+
context: AccessContext | Promise<AccessContext>,
|
|
34
|
+
plugins: TestPlugins,
|
|
35
|
+
) {
|
|
36
|
+
return buildBetterAuthOptions(config, context, plugins)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type NoArgResult = Awaited<ReturnType<typeof callWithNoPlugins>>
|
|
40
|
+
type BuiltOptionsWithPlugins = Awaited<ReturnType<typeof callWithPlugins>>
|
|
41
|
+
type ConstructedAuth = ReturnType<typeof betterAuth<BuiltOptionsWithPlugins>>
|
|
42
|
+
|
|
43
|
+
describe('buildBetterAuthOptions plugin-tuple typing', () => {
|
|
44
|
+
it('back-compat: the no-argument call still returns the widened BetterAuthOptions', () => {
|
|
45
|
+
expectTypeOf<NoArgResult>().toEqualTypeOf<BetterAuthOptions>()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('preserves plugin-derived auth.api.* endpoints and a customSession shape', () => {
|
|
49
|
+
// emailOTP() endpoints exist on the constructed Auth's `api` — erased entirely
|
|
50
|
+
// when constructed from the widened `BetterAuthOptions` (see #876).
|
|
51
|
+
expectTypeOf<ConstructedAuth['api']['signInEmailOTP']>().not.toBeNever()
|
|
52
|
+
expectTypeOf<ConstructedAuth['api']['sendVerificationOTP']>().not.toBeNever()
|
|
53
|
+
expectTypeOf<ConstructedAuth['api']['checkVerificationOTP']>().not.toBeNever()
|
|
54
|
+
|
|
55
|
+
// customSession()'s replaced shape, not better-auth's default { user, session }.
|
|
56
|
+
type GetSessionReturn = Awaited<ReturnType<ConstructedAuth['api']['getSession']>>
|
|
57
|
+
expectTypeOf<GetSessionReturn>().toEqualTypeOf<AppSession | null>()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest'
|
|
2
|
+
import type { OpenSaasConfig, AccessContext, Session } from '@opensaas/stack-core'
|
|
3
|
+
import { emailOTP } from 'better-auth/plugins'
|
|
4
|
+
import { createAuth, getSessionFromAuth } from './index.js'
|
|
5
|
+
|
|
6
|
+
// Type-level regression coverage for #906: `getSessionFromAuth` must accept
|
|
7
|
+
// an auth instance from either `createAuth()` overload — the widened
|
|
8
|
+
// `Auth<BetterAuthOptions>` returned with no plugin tuple, and the narrowed
|
|
9
|
+
// `Auth<ResolvedBetterAuthOptions<TPlugins>>` returned when one is passed —
|
|
10
|
+
// with no cast and no intermediate widening assignment. `Parameters<typeof
|
|
11
|
+
// createAuth>` on the overloaded export resolves against its last (generic)
|
|
12
|
+
// signature rather than the call-site-selected one, so each call shape is
|
|
13
|
+
// wrapped in its own ordinary function and read back via `typeof`, mirroring
|
|
14
|
+
// `build-better-auth-options.test.ts`.
|
|
15
|
+
type TestPlugins = [ReturnType<typeof emailOTP>]
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
|
|
18
|
+
function callWithNoPlugins(
|
|
19
|
+
config: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
20
|
+
context: AccessContext | Promise<AccessContext>,
|
|
21
|
+
) {
|
|
22
|
+
return createAuth(config, context)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced only via `typeof` below
|
|
26
|
+
function callWithPlugins(
|
|
27
|
+
config: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
28
|
+
context: AccessContext | Promise<AccessContext>,
|
|
29
|
+
plugins: TestPlugins,
|
|
30
|
+
) {
|
|
31
|
+
return createAuth(config, context, plugins)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type WidenedAuth = ReturnType<typeof callWithNoPlugins>
|
|
35
|
+
type NarrowedAuth = ReturnType<typeof callWithPlugins>
|
|
36
|
+
|
|
37
|
+
describe('getSessionFromAuth accepts either createAuth() overload (#906)', () => {
|
|
38
|
+
it('accepts the widened Auth<BetterAuthOptions> instance with no cast', () => {
|
|
39
|
+
expectTypeOf<WidenedAuth>().toExtend<Parameters<typeof getSessionFromAuth>[0]>()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('accepts the narrowed, plugin-typed Auth instance with no cast', () => {
|
|
43
|
+
// This is the exact case #906 reported as a type error: a `createAuth`
|
|
44
|
+
// instance narrowed by a plugin tuple, passed straight into
|
|
45
|
+
// `getSessionFromAuth` without widening it back to `Auth<BetterAuthOptions>`.
|
|
46
|
+
expectTypeOf<NarrowedAuth>().toExtend<Parameters<typeof getSessionFromAuth>[0]>()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('return type stays Promise<Session | null>, unchanged by the widened/narrowed instance', () => {
|
|
50
|
+
expectTypeOf(getSessionFromAuth).returns.toEqualTypeOf<Promise<Session | null>>()
|
|
51
|
+
})
|
|
52
|
+
})
|