@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
package/src/server/index.ts
CHANGED
|
@@ -1,11 +1,58 @@
|
|
|
1
1
|
import { betterAuth } from 'better-auth'
|
|
2
2
|
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
|
3
3
|
import { nextCookies } from 'better-auth/next-js'
|
|
4
|
-
import type { BetterAuthOptions } from 'better-auth'
|
|
5
|
-
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
4
|
+
import type { Auth, BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
|
|
5
|
+
import type { OpenSaasConfig, AccessContext, Session } from '@opensaas/stack-core'
|
|
6
6
|
import type { DatabaseConfig } from '@opensaas/stack-core/internal'
|
|
7
7
|
import type { NormalizedAuthConfig, NormalizedAuthModelConfig } from '../config/types.js'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The `BetterAuthOptions` shape produced when an app's own plugin tuple is
|
|
11
|
+
* passed to `buildBetterAuthOptions()`/`createAuth()` — the tuple plus the
|
|
12
|
+
* `nextCookies()` plugin the stack always appends last. Carrying the literal
|
|
13
|
+
* tuple type (rather than the widened `BetterAuthPlugin[]`) is what lets
|
|
14
|
+
* `betterAuth()` re-infer plugin endpoints (e.g. `emailOTP()`'s
|
|
15
|
+
* `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
|
|
16
|
+
* shape from the resulting options object.
|
|
17
|
+
*/
|
|
18
|
+
type ResolvedBetterAuthOptions<TPlugins extends readonly BetterAuthPlugin[]> = Omit<
|
|
19
|
+
BetterAuthOptions,
|
|
20
|
+
'plugins'
|
|
21
|
+
> & {
|
|
22
|
+
plugins: [...TPlugins, ReturnType<typeof nextCookies>]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Guard against the supplied plugin tuple silently drifting from the plugin
|
|
27
|
+
* array actually resolved from `authPlugin({ betterAuthPlugins })` — the
|
|
28
|
+
* supplied tuple exists for typing only, so if it isn't the exact same
|
|
29
|
+
* instances in the exact same order, the type it produces would be a lie
|
|
30
|
+
* about what `betterAuth()` is actually constructed with.
|
|
31
|
+
*/
|
|
32
|
+
function assertPluginTupleMatchesResolved(
|
|
33
|
+
supplied: readonly BetterAuthPlugin[],
|
|
34
|
+
resolved: readonly BetterAuthPlugin[],
|
|
35
|
+
): void {
|
|
36
|
+
if (supplied.length !== resolved.length) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' +
|
|
39
|
+
`has ${supplied.length} plugin(s), but the plugin array resolved from \`authPlugin({ ` +
|
|
40
|
+
`betterAuthPlugins })\` has ${resolved.length}. Pass the exact same array (without ` +
|
|
41
|
+
'`nextCookies()` — the stack appends that itself).',
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mismatchIndex = supplied.findIndex((plugin, index) => plugin !== resolved[index])
|
|
46
|
+
if (mismatchIndex !== -1) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'[@opensaas/stack-auth] The plugin tuple passed to `buildBetterAuthOptions()` / `createAuth()` ' +
|
|
49
|
+
`does not match the plugin array resolved from \`authPlugin({ betterAuthPlugins })\` at index ` +
|
|
50
|
+
`${mismatchIndex} (got plugin "${supplied[mismatchIndex]?.id}", expected the same instance as ` +
|
|
51
|
+
`"${resolved[mismatchIndex]?.id}"). Pass the exact same array — same instances, same order.`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
9
56
|
/**
|
|
10
57
|
* Get better-auth database configuration from OpenSaas config
|
|
11
58
|
*/
|
|
@@ -64,6 +111,21 @@ function assertNoUnsupportedPassthroughKeys(betterAuthOptions: Record<string, un
|
|
|
64
111
|
)
|
|
65
112
|
}
|
|
66
113
|
|
|
114
|
+
const rateLimitOptions = betterAuthOptions.rateLimit
|
|
115
|
+
if (
|
|
116
|
+
rateLimitOptions &&
|
|
117
|
+
typeof rateLimitOptions === 'object' &&
|
|
118
|
+
!Array.isArray(rateLimitOptions) &&
|
|
119
|
+
'storage' in rateLimitOptions
|
|
120
|
+
) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'[@opensaas/stack-auth] `betterAuthOptions.rateLimit.storage` is not supported — it has ' +
|
|
123
|
+
'schema consequences (deriving the `RateLimit` list) that a passthrough cannot also ' +
|
|
124
|
+
"apply to the generated Prisma schema. Use `authPlugin({ rateLimit: { storage: 'database' } })` " +
|
|
125
|
+
'instead.',
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
67
129
|
for (const model of MODELS_WITH_NO_ADDITIONAL_FIELDS_PASSTHROUGH) {
|
|
68
130
|
const modelOptions = betterAuthOptions[model]
|
|
69
131
|
if (
|
|
@@ -125,21 +187,55 @@ function mergeBetterAuthOptions(
|
|
|
125
187
|
* authoritative for everything it models; the app's additions on top become
|
|
126
188
|
* an explicit, reviewable diff instead of a parallel, hand-duplicated config.
|
|
127
189
|
*
|
|
128
|
-
*
|
|
190
|
+
* Called with just `(config, context)`, the return type is the widened
|
|
191
|
+
* `BetterAuthOptions` — `betterAuth()` infers its plugin/session types from
|
|
192
|
+
* the *literal* type of the options object, so constructing from this
|
|
193
|
+
* widened return erases plugin endpoints (e.g. `emailOTP()`'s
|
|
194
|
+
* `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
|
|
195
|
+
* shape. **If your app reads `auth.api.*` in typed code and uses either of
|
|
196
|
+
* those, pass its plugin tuple as the third argument** — the exact same
|
|
197
|
+
* array already passed to `authPlugin({ betterAuthPlugins })` — so the
|
|
198
|
+
* return type carries the literal tuple instead:
|
|
199
|
+
*
|
|
129
200
|
* ```typescript
|
|
130
201
|
* import { betterAuth } from 'better-auth'
|
|
202
|
+
* import { emailOTP } from 'better-auth/plugins'
|
|
131
203
|
* import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
|
|
132
204
|
*
|
|
205
|
+
* export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
|
|
206
|
+
*
|
|
133
207
|
* export const auth = betterAuth({
|
|
134
|
-
* ...(await buildBetterAuthOptions(config, context)),
|
|
208
|
+
* ...(await buildBetterAuthOptions(config, context, appBetterAuthPlugins)),
|
|
135
209
|
* databaseHooks: { user: { create: { after: syncDomainUser } } },
|
|
136
210
|
* })
|
|
211
|
+
* // auth.api.signInEmailOTP / auth.api.getSession()'s customSession shape are now typed.
|
|
137
212
|
* ```
|
|
213
|
+
*
|
|
214
|
+
* The supplied tuple is for typing only — the array actually used at runtime
|
|
215
|
+
* is always the one resolved from `authPlugin({ betterAuthPlugins })`, with
|
|
216
|
+
* exactly one `nextCookies()` appended last. Passing a tuple that isn't the
|
|
217
|
+
* same plugin instances in the same order throws, so the two can't silently
|
|
218
|
+
* drift apart.
|
|
219
|
+
*
|
|
220
|
+
* Note `createAuth()`'s lazy Proxy does not behave identically to a real
|
|
221
|
+
* `Auth` instance for every property (see its own doc comment) — reach for
|
|
222
|
+
* this builder plus `betterAuth()` instead when the app reads `auth.api.*`
|
|
223
|
+
* in typed code.
|
|
138
224
|
*/
|
|
139
225
|
export async function buildBetterAuthOptions(
|
|
140
226
|
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
141
227
|
context: AccessContext | Promise<AccessContext>,
|
|
142
|
-
): Promise<BetterAuthOptions>
|
|
228
|
+
): Promise<BetterAuthOptions>
|
|
229
|
+
export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
230
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
231
|
+
context: AccessContext | Promise<AccessContext>,
|
|
232
|
+
plugins: TPlugins,
|
|
233
|
+
): Promise<ResolvedBetterAuthOptions<TPlugins>>
|
|
234
|
+
export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
235
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
236
|
+
context: AccessContext | Promise<AccessContext>,
|
|
237
|
+
plugins?: TPlugins,
|
|
238
|
+
): Promise<BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>> {
|
|
143
239
|
const resolvedConfig = await Promise.resolve(opensaasConfig)
|
|
144
240
|
const resolvedContext = await Promise.resolve(context)
|
|
145
241
|
|
|
@@ -179,6 +275,11 @@ export async function buildBetterAuthOptions(
|
|
|
179
275
|
|
|
180
276
|
assertNoUnsupportedPassthroughKeys(authConfig.betterAuthOptions as Record<string, unknown>)
|
|
181
277
|
|
|
278
|
+
const resolvedPlugins = authConfig.betterAuthPlugins || []
|
|
279
|
+
if (plugins) {
|
|
280
|
+
assertPluginTupleMatchesResolved(plugins, resolvedPlugins)
|
|
281
|
+
}
|
|
282
|
+
|
|
182
283
|
// Build better-auth configuration
|
|
183
284
|
const betterAuthConfig: BetterAuthOptions = {
|
|
184
285
|
database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
|
|
@@ -244,12 +345,19 @@ export async function buildBetterAuthOptions(
|
|
|
244
345
|
{} as Record<string, { clientId: string; clientSecret: string }>,
|
|
245
346
|
),
|
|
246
347
|
|
|
247
|
-
// Rate limiting configuration
|
|
348
|
+
// Rate limiting configuration. `modelName`/`fields` are only forwarded
|
|
349
|
+
// when `models.rateLimit` was derived (rateLimit.storage === 'database')
|
|
350
|
+
// — mirroring the running instance's model options back to the table the
|
|
351
|
+
// `RateLimit` Auth list was derived from.
|
|
248
352
|
rateLimit: authConfig.rateLimit
|
|
249
353
|
? {
|
|
250
354
|
enabled: authConfig.rateLimit.enabled,
|
|
251
355
|
window: authConfig.rateLimit.window,
|
|
252
356
|
max: authConfig.rateLimit.max,
|
|
357
|
+
...(authConfig.rateLimit.storage ? { storage: authConfig.rateLimit.storage } : {}),
|
|
358
|
+
...(authConfig.models.rateLimit
|
|
359
|
+
? toBetterAuthModelOptions(authConfig.models.rateLimit)
|
|
360
|
+
: {}),
|
|
253
361
|
}
|
|
254
362
|
: undefined,
|
|
255
363
|
|
|
@@ -259,47 +367,81 @@ export async function buildBetterAuthOptions(
|
|
|
259
367
|
// cookie store. This is what makes the server-action auth forms (which
|
|
260
368
|
// call auth.api.signInEmail/signUpEmail/etc. server-side) actually
|
|
261
369
|
// persist a session. It must be the final plugin in the array.
|
|
262
|
-
plugins: [...
|
|
370
|
+
plugins: [...resolvedPlugins, nextCookies()],
|
|
263
371
|
}
|
|
264
372
|
|
|
265
373
|
return mergeBetterAuthOptions(
|
|
266
374
|
betterAuthConfig as unknown as Record<string, unknown>,
|
|
267
375
|
authConfig.betterAuthOptions as Record<string, unknown>,
|
|
268
|
-
) as BetterAuthOptions
|
|
376
|
+
) as BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>
|
|
269
377
|
}
|
|
270
378
|
|
|
271
379
|
/**
|
|
272
380
|
* Create a better-auth instance from OpenSaas config
|
|
273
381
|
* This should be called once at app startup
|
|
274
382
|
*
|
|
275
|
-
*
|
|
383
|
+
* Returns a lazy `Proxy` (see the caveat below), typed as `Auth<BetterAuthOptions>`
|
|
384
|
+
* when called with just `(config, context)` — the widened type, same erasure
|
|
385
|
+
* caveat as {@link buildBetterAuthOptions}'s no-argument form. **If your app
|
|
386
|
+
* reads `auth.api.*` in typed code and relies on a plugin's endpoints (e.g.
|
|
387
|
+
* `emailOTP()`) or a `customSession()`'s replaced session shape, pass its
|
|
388
|
+
* plugin tuple as the third argument** — the exact same array already passed
|
|
389
|
+
* to `authPlugin({ betterAuthPlugins })` — so the declared type carries the
|
|
390
|
+
* literal tuple instead:
|
|
391
|
+
*
|
|
276
392
|
* ```typescript
|
|
277
393
|
* // lib/auth.ts
|
|
278
394
|
* import { createAuth } from '@opensaas/stack-auth/server'
|
|
279
395
|
* import config from '../opensaas.config'
|
|
280
396
|
* import { rawOpensaasContext } from '@/.opensaas/context'
|
|
281
397
|
*
|
|
282
|
-
* export const
|
|
398
|
+
* export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
|
|
399
|
+
*
|
|
400
|
+
* export const auth = createAuth(config, rawOpensaasContext, appBetterAuthPlugins)
|
|
283
401
|
* ```
|
|
402
|
+
*
|
|
403
|
+
* As with the builder, the supplied tuple is for typing only, and a tuple
|
|
404
|
+
* that isn't the same plugin instances in the same order throws.
|
|
405
|
+
*
|
|
406
|
+
* **Proxy caveat:** the lazy `Proxy` this returns does not behave identically
|
|
407
|
+
* to a real `Auth` instance for every property — every access, including a
|
|
408
|
+
* non-function property, is surfaced through an `async` wrapper (so e.g.
|
|
409
|
+
* `auth.options` reads back as a `Promise`, not the plain object a real
|
|
410
|
+
* instance would return synchronously). The declared type does not model
|
|
411
|
+
* this difference; where it matters, reach for {@link buildBetterAuthOptions}
|
|
412
|
+
* plus `betterAuth()` instead, which constructs a real instance.
|
|
284
413
|
*/
|
|
285
414
|
export function createAuth(
|
|
286
415
|
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
287
416
|
context: AccessContext | Promise<AccessContext>,
|
|
288
|
-
)
|
|
417
|
+
): Auth<BetterAuthOptions>
|
|
418
|
+
export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
419
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
420
|
+
context: AccessContext | Promise<AccessContext>,
|
|
421
|
+
plugins: TPlugins,
|
|
422
|
+
): Auth<ResolvedBetterAuthOptions<TPlugins>>
|
|
423
|
+
export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
424
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
425
|
+
context: AccessContext | Promise<AccessContext>,
|
|
426
|
+
plugins?: TPlugins,
|
|
427
|
+
): Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>> {
|
|
289
428
|
// Resolve config and context asynchronously
|
|
290
429
|
const configPromise = Promise.resolve(opensaasConfig)
|
|
291
430
|
const contextPromise = Promise.resolve(context)
|
|
292
431
|
|
|
293
432
|
// Create auth instance lazily when needed
|
|
294
|
-
|
|
295
|
-
let
|
|
433
|
+
type AuthInstance = Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>>
|
|
434
|
+
let authInstance: AuthInstance | null = null
|
|
435
|
+
let authPromise: Promise<AuthInstance> | null = null
|
|
296
436
|
|
|
297
437
|
async function getAuthInstance() {
|
|
298
438
|
if (authInstance) return authInstance
|
|
299
439
|
|
|
300
440
|
if (!authPromise) {
|
|
301
441
|
authPromise = (async () => {
|
|
302
|
-
const betterAuthConfig =
|
|
442
|
+
const betterAuthConfig = plugins
|
|
443
|
+
? await buildBetterAuthOptions(configPromise, contextPromise, plugins)
|
|
444
|
+
: await buildBetterAuthOptions(configPromise, contextPromise)
|
|
303
445
|
authInstance = betterAuth(betterAuthConfig)
|
|
304
446
|
return authInstance
|
|
305
447
|
})()
|
|
@@ -309,7 +451,7 @@ export function createAuth(
|
|
|
309
451
|
}
|
|
310
452
|
|
|
311
453
|
// Return a proxy that lazily initializes the auth instance
|
|
312
|
-
return new Proxy({} as
|
|
454
|
+
return new Proxy({} as AuthInstance, {
|
|
313
455
|
get(_, prop) {
|
|
314
456
|
if (prop === 'then') {
|
|
315
457
|
// Support await on the proxy itself
|
|
@@ -355,41 +497,131 @@ export function createAuth(
|
|
|
355
497
|
}
|
|
356
498
|
|
|
357
499
|
/**
|
|
358
|
-
*
|
|
500
|
+
* Field names already warned about failing to resolve against a session, so a
|
|
501
|
+
* given field warns at most once per process rather than once per request.
|
|
502
|
+
*/
|
|
503
|
+
const unresolvedSessionFieldWarnings = new Set<string>()
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Warn (once per field, per process) that a `sessionFields` entry could not
|
|
507
|
+
* be resolved from the session shape `auth.api.getSession()` actually
|
|
508
|
+
* returned — naming the field and what keys were available to check, so the
|
|
509
|
+
* gap is visible here instead of surfacing later as an access-control
|
|
510
|
+
* function silently reading `undefined`.
|
|
511
|
+
*/
|
|
512
|
+
function warnUnresolvedSessionField(field: string, resolvedSession: Record<string, unknown>): void {
|
|
513
|
+
if (unresolvedSessionFieldWarnings.has(field)) return
|
|
514
|
+
unresolvedSessionFieldWarnings.add(field)
|
|
515
|
+
|
|
516
|
+
const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
|
|
517
|
+
const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
|
|
518
|
+
|
|
519
|
+
console.warn(
|
|
520
|
+
`[@opensaas/stack-auth] sessionFields: "${field}" was not found on the resolved session. ` +
|
|
521
|
+
`Checked its top-level keys (${Object.keys(resolvedSession).join(', ') || 'none'}), ` +
|
|
522
|
+
`its "user" object (${user ? Object.keys(user).join(', ') || 'none' : 'not present'}), ` +
|
|
523
|
+
`and its "session" object (${sessionRow ? Object.keys(sessionRow).join(', ') || 'none' : 'not present'}). ` +
|
|
524
|
+
`The field is omitted from the projected session. A \`customSession\` plugin that nests this ` +
|
|
525
|
+
`value elsewhere is the app's own to reconcile — see the \`sessionFields\` reference. ` +
|
|
526
|
+
`This warning will not repeat for "${field}".`,
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Resolve a single `sessionFields` entry off the resolved better-auth
|
|
532
|
+
* session (whatever `auth.api.getSession()` returned — the default `{
|
|
533
|
+
* session, user }` shape, or a `customSession` plugin's replaced shape).
|
|
534
|
+
*
|
|
535
|
+
* `userId` is special-cased to the authenticated user's `id` — the
|
|
536
|
+
* documented default apps depend on. Every other name resolves against a
|
|
537
|
+
* fixed precedence so a collision between sources is predictable rather
|
|
538
|
+
* than incidental: a top-level key on the resolved session object, then the
|
|
539
|
+
* `user` object, then the `session` sub-object.
|
|
540
|
+
*/
|
|
541
|
+
function resolveSessionField(
|
|
542
|
+
field: string,
|
|
543
|
+
resolvedSession: Record<string, unknown>,
|
|
544
|
+
): { found: true; value: unknown } | { found: false } {
|
|
545
|
+
const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
|
|
546
|
+
|
|
547
|
+
if (field === 'userId') {
|
|
548
|
+
return user && 'id' in user ? { found: true, value: user.id } : { found: false }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (field in resolvedSession) {
|
|
552
|
+
return { found: true, value: resolvedSession[field] }
|
|
553
|
+
}
|
|
554
|
+
if (user && field in user) {
|
|
555
|
+
return { found: true, value: user[field] }
|
|
556
|
+
}
|
|
557
|
+
const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
|
|
558
|
+
if (sessionRow && field in sessionRow) {
|
|
559
|
+
return { found: true, value: sessionRow[field] }
|
|
560
|
+
}
|
|
561
|
+
return { found: false }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Get session from better-auth and transform it to OpenSaas session format —
|
|
566
|
+
* a flattened projection of `sessionFields` off the *resolved* session
|
|
567
|
+
* object, not just its `user` sub-object. This is what makes a
|
|
568
|
+
* `customSession` plugin's fields (added at the top level, or a
|
|
569
|
+
* session-only field like the admin plugin's `impersonatedBy`) reachable.
|
|
570
|
+
* See the `sessionFields` reference for the resolution precedence.
|
|
571
|
+
*
|
|
572
|
+
* Returns `null` only when there is genuinely no session — a resolved
|
|
573
|
+
* session with no `user` key (a `customSession` plugin that dropped it) is
|
|
574
|
+
* still a session and still gets projected, never misreported as anonymous.
|
|
575
|
+
* A listed field that can't be resolved from the session shape is omitted
|
|
576
|
+
* and warns once per field per process (see `warnUnresolvedSessionField`)
|
|
577
|
+
* instead of silently vanishing into an access-control function reading
|
|
578
|
+
* `undefined`.
|
|
579
|
+
*
|
|
580
|
+
* Errors from the underlying `auth.api.getSession()` call propagate rather
|
|
581
|
+
* than becoming `null` — collapsing a lookup failure (e.g. a session-store
|
|
582
|
+
* outage) into "anonymous" is indistinguishable from a mass sign-out under
|
|
583
|
+
* fail-closed access control, so the caller must see it.
|
|
584
|
+
*
|
|
585
|
+
* Not called by any generated code before this helper existed — apps used to
|
|
586
|
+
* hand-roll this same transform against `auth.api.getSession({ headers:
|
|
587
|
+
* await headers() })`. Exported as the single reusable implementation; pass
|
|
588
|
+
* the caller's request headers (e.g. Next.js `await headers()` in a Server
|
|
589
|
+
* Component/action) so a session cookie can actually be resolved.
|
|
359
590
|
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
591
|
+
* `auth` is typed structurally over just the one member this function reads
|
|
592
|
+
* — `api.getSession` — rather than a single concrete `Auth<Options>`
|
|
593
|
+
* instantiation, so it accepts an instance from either `createAuth()`
|
|
594
|
+
* overload: the widened `Auth<BetterAuthOptions>`, or the narrowed
|
|
595
|
+
* `Auth<ResolvedBetterAuthOptions<TPlugins>>` returned when a plugin tuple is
|
|
596
|
+
* passed. `TResolvedSession` is inferred from whatever `auth.api.getSession`
|
|
597
|
+
* actually returns (the default `{ session, user }` shape, or a
|
|
598
|
+
* `customSession` plugin's replaced shape) — it is not constrained, so no
|
|
599
|
+
* `any`/`unknown` is introduced at the call boundary.
|
|
365
600
|
*/
|
|
366
|
-
export async function getSessionFromAuth(
|
|
367
|
-
auth:
|
|
601
|
+
export async function getSessionFromAuth<TResolvedSession>(
|
|
602
|
+
auth: { api: { getSession: (args: { headers: Headers }) => Promise<TResolvedSession> } },
|
|
368
603
|
sessionFields: string[],
|
|
369
604
|
headers: Headers,
|
|
370
|
-
) {
|
|
371
|
-
|
|
372
|
-
const session = await auth.api.getSession({ headers })
|
|
605
|
+
): Promise<Session | null> {
|
|
606
|
+
const resolvedSession = await auth.api.getSession({ headers })
|
|
373
607
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
608
|
+
if (!resolvedSession) {
|
|
609
|
+
return null
|
|
610
|
+
}
|
|
377
611
|
|
|
378
|
-
|
|
379
|
-
|
|
612
|
+
const resolvedSessionRecord = resolvedSession as Record<string, unknown>
|
|
613
|
+
const result: Record<string, unknown> = {}
|
|
380
614
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
615
|
+
for (const field of sessionFields) {
|
|
616
|
+
const resolved = resolveSessionField(field, resolvedSessionRecord)
|
|
617
|
+
if (resolved.found) {
|
|
618
|
+
result[field] = resolved.value
|
|
619
|
+
} else {
|
|
620
|
+
warnUnresolvedSessionField(field, resolvedSessionRecord)
|
|
387
621
|
}
|
|
388
|
-
|
|
389
|
-
return result
|
|
390
|
-
} catch {
|
|
391
|
-
return null
|
|
392
622
|
}
|
|
623
|
+
|
|
624
|
+
return result
|
|
393
625
|
}
|
|
394
626
|
|
|
395
627
|
export type { BetterAuthOptions }
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { list } from '@opensaas/stack-core'
|
|
7
|
-
import { text, timestamp, checkbox, integer } from '@opensaas/stack-core/fields'
|
|
7
|
+
import { text, timestamp, checkbox, integer, bigInt } from '@opensaas/stack-core/fields'
|
|
8
8
|
import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -12,24 +12,32 @@ import type { ListConfig, FieldConfig } from '@opensaas/stack-core'
|
|
|
12
12
|
* Inferred from better-auth internal types
|
|
13
13
|
*/
|
|
14
14
|
type BetterAuthFieldAttribute = {
|
|
15
|
-
type: string // 'string' | 'number' | 'boolean' | 'date' | etc
|
|
15
|
+
type: string | string[] // 'string' | 'number' | 'boolean' | 'date' | etc., or an enum array
|
|
16
16
|
required?: boolean
|
|
17
17
|
unique?: boolean
|
|
18
18
|
references?: {
|
|
19
19
|
model: string
|
|
20
20
|
field: string
|
|
21
|
-
onDelete?: 'cascade' | 'set null' | '
|
|
21
|
+
onDelete?: 'no action' | 'restrict' | 'cascade' | 'set null' | 'set default'
|
|
22
22
|
}
|
|
23
23
|
defaultValue?: unknown
|
|
24
24
|
returned?: boolean
|
|
25
25
|
input?: boolean
|
|
26
|
+
/**
|
|
27
|
+
* "If the field should be a bigint on the database instead of integer" —
|
|
28
|
+
* only meaningful alongside `type: 'number'`. Modelled here so the `number`
|
|
29
|
+
* branch below can read it; better-auth's rate limiter is the only shipped
|
|
30
|
+
* example (`lastRequest`, a millisecond epoch that overflows a 32-bit
|
|
31
|
+
* `Int`), but any plugin can declare it (issue #917).
|
|
32
|
+
*/
|
|
33
|
+
bigint?: boolean
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
37
|
* Better Auth table schema structure
|
|
30
38
|
*/
|
|
31
39
|
type BetterAuthTableSchema = {
|
|
32
|
-
modelName
|
|
40
|
+
modelName?: string
|
|
33
41
|
fields: Record<string, BetterAuthFieldAttribute>
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -40,7 +48,7 @@ function convertField(
|
|
|
40
48
|
fieldName: string,
|
|
41
49
|
betterAuthField: BetterAuthFieldAttribute,
|
|
42
50
|
): FieldConfig | null {
|
|
43
|
-
const { type, required, unique, defaultValue, references } = betterAuthField
|
|
51
|
+
const { type, required, unique, defaultValue, references, bigint } = betterAuthField
|
|
44
52
|
|
|
45
53
|
// System fields are auto-generated by OpenSaaS
|
|
46
54
|
if (fieldName === 'id' || fieldName === 'createdAt' || fieldName === 'updatedAt') {
|
|
@@ -64,6 +72,16 @@ function convertField(
|
|
|
64
72
|
})
|
|
65
73
|
|
|
66
74
|
case 'number':
|
|
75
|
+
// A millisecond epoch (e.g. Date.now()) overflows a 32-bit Int, so a
|
|
76
|
+
// field declaring `bigint: true` must generate a BigInt column instead
|
|
77
|
+
// (issue #917) — matching what deriveAuthLists produces for the same
|
|
78
|
+
// upstream declaration (e.g. the rate limiter's `lastRequest`).
|
|
79
|
+
if (bigint) {
|
|
80
|
+
return bigInt({
|
|
81
|
+
validation: { isRequired: required },
|
|
82
|
+
defaultValue: typeof defaultValue === 'number' ? defaultValue : undefined,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
67
85
|
return integer({
|
|
68
86
|
validation: { isRequired: required },
|
|
69
87
|
defaultValue: typeof defaultValue === 'number' ? defaultValue : undefined,
|
|
@@ -137,10 +155,11 @@ export function convertTableToList(
|
|
|
137
155
|
}
|
|
138
156
|
|
|
139
157
|
/**
|
|
140
|
-
* Base better-auth model keys — the
|
|
141
|
-
*
|
|
158
|
+
* Base better-auth model keys — the tables the auth plugin's own model config
|
|
159
|
+
* (`user`/`session`/`account`/`verification`, plus `rateLimit` when
|
|
160
|
+
* `rateLimit.storage: 'database'` derives it) can remap via `modelName`.
|
|
142
161
|
*/
|
|
143
|
-
type BaseAuthModelKey = 'user' | 'session' | 'account' | 'verification'
|
|
162
|
+
type BaseAuthModelKey = 'user' | 'session' | 'account' | 'verification' | 'rateLimit'
|
|
144
163
|
|
|
145
164
|
/**
|
|
146
165
|
* Resolved list key for each base better-auth model, as derived by
|
|
@@ -171,6 +190,8 @@ function resolveBaseModelKey(
|
|
|
171
190
|
return baseModelKeys.account
|
|
172
191
|
case 'verification':
|
|
173
192
|
return baseModelKeys.verification
|
|
193
|
+
case 'ratelimit':
|
|
194
|
+
return baseModelKeys.rateLimit
|
|
174
195
|
default:
|
|
175
196
|
return undefined
|
|
176
197
|
}
|
|
@@ -280,3 +280,76 @@ describe('adoptBetterAuthTables - clean-diff adoption (Auth lists ≠ app User)'
|
|
|
280
280
|
expect(result.lists.AuthSession.fields.user.db?.foreignKey).toEqual({ map: 'user_id' })
|
|
281
281
|
})
|
|
282
282
|
})
|
|
283
|
+
|
|
284
|
+
describe('adoptBetterAuthTables - rateLimit adoption (issue #909)', () => {
|
|
285
|
+
it('omits rateLimit from the fragment by default', () => {
|
|
286
|
+
const fragment = adoptBetterAuthTables()
|
|
287
|
+
|
|
288
|
+
expect(fragment.rateLimit).toBeUndefined()
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('adds a prefixed, database-storage rateLimit fragment when opted in', () => {
|
|
292
|
+
const fragment = adoptBetterAuthTables({ rateLimit: true })
|
|
293
|
+
|
|
294
|
+
expect(fragment.rateLimit).toEqual({
|
|
295
|
+
enabled: true,
|
|
296
|
+
storage: 'database',
|
|
297
|
+
modelName: 'AuthRateLimit',
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('honours a custom schema/prefix on the rateLimit fragment like the other four models', () => {
|
|
302
|
+
const fragment = adoptBetterAuthTables({
|
|
303
|
+
rateLimit: true,
|
|
304
|
+
schema: 'identity',
|
|
305
|
+
modelNamePrefix: 'BA',
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
expect(fragment.rateLimit).toEqual({
|
|
309
|
+
enabled: true,
|
|
310
|
+
storage: 'database',
|
|
311
|
+
modelName: 'BARateLimit',
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('useBetterAuthTableNames sets the rateLimit tableName to the better-auth default', () => {
|
|
316
|
+
const fragment = adoptBetterAuthTables({ rateLimit: true, useBetterAuthTableNames: true })
|
|
317
|
+
|
|
318
|
+
expect(fragment.rateLimit).toEqual({
|
|
319
|
+
enabled: true,
|
|
320
|
+
storage: 'database',
|
|
321
|
+
modelName: 'AuthRateLimit',
|
|
322
|
+
tableName: 'rateLimit',
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('merges a rateLimit field column map when provided', () => {
|
|
327
|
+
const fragment = adoptBetterAuthTables({
|
|
328
|
+
rateLimit: true,
|
|
329
|
+
fields: { rateLimit: { key: 'limit_key' } },
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
expect(fragment.rateLimit).toEqual({
|
|
333
|
+
enabled: true,
|
|
334
|
+
storage: 'database',
|
|
335
|
+
modelName: 'AuthRateLimit',
|
|
336
|
+
fields: { key: 'limit_key' },
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('composes with authPlugin to actually derive the fifth Auth list', async () => {
|
|
341
|
+
const result = await generationConfig({
|
|
342
|
+
db: { provider: 'postgresql' },
|
|
343
|
+
plugins: [
|
|
344
|
+
authPlugin({
|
|
345
|
+
...adoptBetterAuthTables({ rateLimit: true }),
|
|
346
|
+
emailAndPassword: { enabled: true },
|
|
347
|
+
}),
|
|
348
|
+
],
|
|
349
|
+
lists: {},
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
expect(result.lists).toHaveProperty('AuthRateLimit')
|
|
353
|
+
expect(result.lists.AuthRateLimit.db?.schema).toBe('auth')
|
|
354
|
+
})
|
|
355
|
+
})
|