@opensaas/stack-auth 0.37.0 → 0.38.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 +37 -0
- package/CLAUDE.md +50 -3
- package/dist/config/plugin.js +1 -1
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +18 -5
- 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/index.d.ts +92 -14
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +123 -62
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema-converter.d.ts +3 -3
- package/dist/server/schema-converter.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/config/plugin.ts +1 -1
- package/src/config/types.ts +18 -7
- package/src/server/build-better-auth-options.test.ts +59 -0
- package/src/server/index.ts +238 -38
- package/src/server/schema-converter.ts +3 -3
- package/tests/server.test.ts +210 -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
|
*/
|
|
@@ -125,21 +172,55 @@ function mergeBetterAuthOptions(
|
|
|
125
172
|
* authoritative for everything it models; the app's additions on top become
|
|
126
173
|
* an explicit, reviewable diff instead of a parallel, hand-duplicated config.
|
|
127
174
|
*
|
|
128
|
-
*
|
|
175
|
+
* Called with just `(config, context)`, the return type is the widened
|
|
176
|
+
* `BetterAuthOptions` — `betterAuth()` infers its plugin/session types from
|
|
177
|
+
* the *literal* type of the options object, so constructing from this
|
|
178
|
+
* widened return erases plugin endpoints (e.g. `emailOTP()`'s
|
|
179
|
+
* `api.signInEmailOTP`) and a `customSession()` plugin's replaced session
|
|
180
|
+
* shape. **If your app reads `auth.api.*` in typed code and uses either of
|
|
181
|
+
* those, pass its plugin tuple as the third argument** — the exact same
|
|
182
|
+
* array already passed to `authPlugin({ betterAuthPlugins })` — so the
|
|
183
|
+
* return type carries the literal tuple instead:
|
|
184
|
+
*
|
|
129
185
|
* ```typescript
|
|
130
186
|
* import { betterAuth } from 'better-auth'
|
|
187
|
+
* import { emailOTP } from 'better-auth/plugins'
|
|
131
188
|
* import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
|
|
132
189
|
*
|
|
190
|
+
* export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
|
|
191
|
+
*
|
|
133
192
|
* export const auth = betterAuth({
|
|
134
|
-
* ...(await buildBetterAuthOptions(config, context)),
|
|
193
|
+
* ...(await buildBetterAuthOptions(config, context, appBetterAuthPlugins)),
|
|
135
194
|
* databaseHooks: { user: { create: { after: syncDomainUser } } },
|
|
136
195
|
* })
|
|
196
|
+
* // auth.api.signInEmailOTP / auth.api.getSession()'s customSession shape are now typed.
|
|
137
197
|
* ```
|
|
198
|
+
*
|
|
199
|
+
* The supplied tuple is for typing only — the array actually used at runtime
|
|
200
|
+
* is always the one resolved from `authPlugin({ betterAuthPlugins })`, with
|
|
201
|
+
* exactly one `nextCookies()` appended last. Passing a tuple that isn't the
|
|
202
|
+
* same plugin instances in the same order throws, so the two can't silently
|
|
203
|
+
* drift apart.
|
|
204
|
+
*
|
|
205
|
+
* Note `createAuth()`'s lazy Proxy does not behave identically to a real
|
|
206
|
+
* `Auth` instance for every property (see its own doc comment) — reach for
|
|
207
|
+
* this builder plus `betterAuth()` instead when the app reads `auth.api.*`
|
|
208
|
+
* in typed code.
|
|
138
209
|
*/
|
|
139
210
|
export async function buildBetterAuthOptions(
|
|
140
211
|
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
141
212
|
context: AccessContext | Promise<AccessContext>,
|
|
142
|
-
): Promise<BetterAuthOptions>
|
|
213
|
+
): Promise<BetterAuthOptions>
|
|
214
|
+
export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
215
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
216
|
+
context: AccessContext | Promise<AccessContext>,
|
|
217
|
+
plugins: TPlugins,
|
|
218
|
+
): Promise<ResolvedBetterAuthOptions<TPlugins>>
|
|
219
|
+
export async function buildBetterAuthOptions<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
220
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
221
|
+
context: AccessContext | Promise<AccessContext>,
|
|
222
|
+
plugins?: TPlugins,
|
|
223
|
+
): Promise<BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>> {
|
|
143
224
|
const resolvedConfig = await Promise.resolve(opensaasConfig)
|
|
144
225
|
const resolvedContext = await Promise.resolve(context)
|
|
145
226
|
|
|
@@ -179,6 +260,11 @@ export async function buildBetterAuthOptions(
|
|
|
179
260
|
|
|
180
261
|
assertNoUnsupportedPassthroughKeys(authConfig.betterAuthOptions as Record<string, unknown>)
|
|
181
262
|
|
|
263
|
+
const resolvedPlugins = authConfig.betterAuthPlugins || []
|
|
264
|
+
if (plugins) {
|
|
265
|
+
assertPluginTupleMatchesResolved(plugins, resolvedPlugins)
|
|
266
|
+
}
|
|
267
|
+
|
|
182
268
|
// Build better-auth configuration
|
|
183
269
|
const betterAuthConfig: BetterAuthOptions = {
|
|
184
270
|
database: getDatabaseConfig(resolvedConfig.db, resolvedContext),
|
|
@@ -259,47 +345,81 @@ export async function buildBetterAuthOptions(
|
|
|
259
345
|
// cookie store. This is what makes the server-action auth forms (which
|
|
260
346
|
// call auth.api.signInEmail/signUpEmail/etc. server-side) actually
|
|
261
347
|
// persist a session. It must be the final plugin in the array.
|
|
262
|
-
plugins: [...
|
|
348
|
+
plugins: [...resolvedPlugins, nextCookies()],
|
|
263
349
|
}
|
|
264
350
|
|
|
265
351
|
return mergeBetterAuthOptions(
|
|
266
352
|
betterAuthConfig as unknown as Record<string, unknown>,
|
|
267
353
|
authConfig.betterAuthOptions as Record<string, unknown>,
|
|
268
|
-
) as BetterAuthOptions
|
|
354
|
+
) as BetterAuthOptions | ResolvedBetterAuthOptions<TPlugins>
|
|
269
355
|
}
|
|
270
356
|
|
|
271
357
|
/**
|
|
272
358
|
* Create a better-auth instance from OpenSaas config
|
|
273
359
|
* This should be called once at app startup
|
|
274
360
|
*
|
|
275
|
-
*
|
|
361
|
+
* Returns a lazy `Proxy` (see the caveat below), typed as `Auth<BetterAuthOptions>`
|
|
362
|
+
* when called with just `(config, context)` — the widened type, same erasure
|
|
363
|
+
* caveat as {@link buildBetterAuthOptions}'s no-argument form. **If your app
|
|
364
|
+
* reads `auth.api.*` in typed code and relies on a plugin's endpoints (e.g.
|
|
365
|
+
* `emailOTP()`) or a `customSession()`'s replaced session shape, pass its
|
|
366
|
+
* plugin tuple as the third argument** — the exact same array already passed
|
|
367
|
+
* to `authPlugin({ betterAuthPlugins })` — so the declared type carries the
|
|
368
|
+
* literal tuple instead:
|
|
369
|
+
*
|
|
276
370
|
* ```typescript
|
|
277
371
|
* // lib/auth.ts
|
|
278
372
|
* import { createAuth } from '@opensaas/stack-auth/server'
|
|
279
373
|
* import config from '../opensaas.config'
|
|
280
374
|
* import { rawOpensaasContext } from '@/.opensaas/context'
|
|
281
375
|
*
|
|
282
|
-
* export const
|
|
376
|
+
* export const appBetterAuthPlugins = [emailOTP()] // same array passed to authPlugin({ betterAuthPlugins })
|
|
377
|
+
*
|
|
378
|
+
* export const auth = createAuth(config, rawOpensaasContext, appBetterAuthPlugins)
|
|
283
379
|
* ```
|
|
380
|
+
*
|
|
381
|
+
* As with the builder, the supplied tuple is for typing only, and a tuple
|
|
382
|
+
* that isn't the same plugin instances in the same order throws.
|
|
383
|
+
*
|
|
384
|
+
* **Proxy caveat:** the lazy `Proxy` this returns does not behave identically
|
|
385
|
+
* to a real `Auth` instance for every property — every access, including a
|
|
386
|
+
* non-function property, is surfaced through an `async` wrapper (so e.g.
|
|
387
|
+
* `auth.options` reads back as a `Promise`, not the plain object a real
|
|
388
|
+
* instance would return synchronously). The declared type does not model
|
|
389
|
+
* this difference; where it matters, reach for {@link buildBetterAuthOptions}
|
|
390
|
+
* plus `betterAuth()` instead, which constructs a real instance.
|
|
284
391
|
*/
|
|
285
392
|
export function createAuth(
|
|
286
393
|
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
287
394
|
context: AccessContext | Promise<AccessContext>,
|
|
288
|
-
)
|
|
395
|
+
): Auth<BetterAuthOptions>
|
|
396
|
+
export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
397
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
398
|
+
context: AccessContext | Promise<AccessContext>,
|
|
399
|
+
plugins: TPlugins,
|
|
400
|
+
): Auth<ResolvedBetterAuthOptions<TPlugins>>
|
|
401
|
+
export function createAuth<const TPlugins extends readonly BetterAuthPlugin[]>(
|
|
402
|
+
opensaasConfig: OpenSaasConfig | Promise<OpenSaasConfig>,
|
|
403
|
+
context: AccessContext | Promise<AccessContext>,
|
|
404
|
+
plugins?: TPlugins,
|
|
405
|
+
): Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>> {
|
|
289
406
|
// Resolve config and context asynchronously
|
|
290
407
|
const configPromise = Promise.resolve(opensaasConfig)
|
|
291
408
|
const contextPromise = Promise.resolve(context)
|
|
292
409
|
|
|
293
410
|
// Create auth instance lazily when needed
|
|
294
|
-
|
|
295
|
-
let
|
|
411
|
+
type AuthInstance = Auth<BetterAuthOptions> | Auth<ResolvedBetterAuthOptions<TPlugins>>
|
|
412
|
+
let authInstance: AuthInstance | null = null
|
|
413
|
+
let authPromise: Promise<AuthInstance> | null = null
|
|
296
414
|
|
|
297
415
|
async function getAuthInstance() {
|
|
298
416
|
if (authInstance) return authInstance
|
|
299
417
|
|
|
300
418
|
if (!authPromise) {
|
|
301
419
|
authPromise = (async () => {
|
|
302
|
-
const betterAuthConfig =
|
|
420
|
+
const betterAuthConfig = plugins
|
|
421
|
+
? await buildBetterAuthOptions(configPromise, contextPromise, plugins)
|
|
422
|
+
: await buildBetterAuthOptions(configPromise, contextPromise)
|
|
303
423
|
authInstance = betterAuth(betterAuthConfig)
|
|
304
424
|
return authInstance
|
|
305
425
|
})()
|
|
@@ -309,7 +429,7 @@ export function createAuth(
|
|
|
309
429
|
}
|
|
310
430
|
|
|
311
431
|
// Return a proxy that lazily initializes the auth instance
|
|
312
|
-
return new Proxy({} as
|
|
432
|
+
return new Proxy({} as AuthInstance, {
|
|
313
433
|
get(_, prop) {
|
|
314
434
|
if (prop === 'then') {
|
|
315
435
|
// Support await on the proxy itself
|
|
@@ -355,41 +475,121 @@ export function createAuth(
|
|
|
355
475
|
}
|
|
356
476
|
|
|
357
477
|
/**
|
|
358
|
-
*
|
|
478
|
+
* Field names already warned about failing to resolve against a session, so a
|
|
479
|
+
* given field warns at most once per process rather than once per request.
|
|
480
|
+
*/
|
|
481
|
+
const unresolvedSessionFieldWarnings = new Set<string>()
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Warn (once per field, per process) that a `sessionFields` entry could not
|
|
485
|
+
* be resolved from the session shape `auth.api.getSession()` actually
|
|
486
|
+
* returned — naming the field and what keys were available to check, so the
|
|
487
|
+
* gap is visible here instead of surfacing later as an access-control
|
|
488
|
+
* function silently reading `undefined`.
|
|
489
|
+
*/
|
|
490
|
+
function warnUnresolvedSessionField(field: string, resolvedSession: Record<string, unknown>): void {
|
|
491
|
+
if (unresolvedSessionFieldWarnings.has(field)) return
|
|
492
|
+
unresolvedSessionFieldWarnings.add(field)
|
|
493
|
+
|
|
494
|
+
const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
|
|
495
|
+
const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
|
|
496
|
+
|
|
497
|
+
console.warn(
|
|
498
|
+
`[@opensaas/stack-auth] sessionFields: "${field}" was not found on the resolved session. ` +
|
|
499
|
+
`Checked its top-level keys (${Object.keys(resolvedSession).join(', ') || 'none'}), ` +
|
|
500
|
+
`its "user" object (${user ? Object.keys(user).join(', ') || 'none' : 'not present'}), ` +
|
|
501
|
+
`and its "session" object (${sessionRow ? Object.keys(sessionRow).join(', ') || 'none' : 'not present'}). ` +
|
|
502
|
+
`The field is omitted from the projected session. A \`customSession\` plugin that nests this ` +
|
|
503
|
+
`value elsewhere is the app's own to reconcile — see the \`sessionFields\` reference. ` +
|
|
504
|
+
`This warning will not repeat for "${field}".`,
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Resolve a single `sessionFields` entry off the resolved better-auth
|
|
510
|
+
* session (whatever `auth.api.getSession()` returned — the default `{
|
|
511
|
+
* session, user }` shape, or a `customSession` plugin's replaced shape).
|
|
512
|
+
*
|
|
513
|
+
* `userId` is special-cased to the authenticated user's `id` — the
|
|
514
|
+
* documented default apps depend on. Every other name resolves against a
|
|
515
|
+
* fixed precedence so a collision between sources is predictable rather
|
|
516
|
+
* than incidental: a top-level key on the resolved session object, then the
|
|
517
|
+
* `user` object, then the `session` sub-object.
|
|
518
|
+
*/
|
|
519
|
+
function resolveSessionField(
|
|
520
|
+
field: string,
|
|
521
|
+
resolvedSession: Record<string, unknown>,
|
|
522
|
+
): { found: true; value: unknown } | { found: false } {
|
|
523
|
+
const user = isPlainObject(resolvedSession.user) ? resolvedSession.user : undefined
|
|
524
|
+
|
|
525
|
+
if (field === 'userId') {
|
|
526
|
+
return user && 'id' in user ? { found: true, value: user.id } : { found: false }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (field in resolvedSession) {
|
|
530
|
+
return { found: true, value: resolvedSession[field] }
|
|
531
|
+
}
|
|
532
|
+
if (user && field in user) {
|
|
533
|
+
return { found: true, value: user[field] }
|
|
534
|
+
}
|
|
535
|
+
const sessionRow = isPlainObject(resolvedSession.session) ? resolvedSession.session : undefined
|
|
536
|
+
if (sessionRow && field in sessionRow) {
|
|
537
|
+
return { found: true, value: sessionRow[field] }
|
|
538
|
+
}
|
|
539
|
+
return { found: false }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Get session from better-auth and transform it to OpenSaas session format —
|
|
544
|
+
* a flattened projection of `sessionFields` off the *resolved* session
|
|
545
|
+
* object, not just its `user` sub-object. This is what makes a
|
|
546
|
+
* `customSession` plugin's fields (added at the top level, or a
|
|
547
|
+
* session-only field like the admin plugin's `impersonatedBy`) reachable.
|
|
548
|
+
* See the `sessionFields` reference for the resolution precedence.
|
|
549
|
+
*
|
|
550
|
+
* Returns `null` only when there is genuinely no session — a resolved
|
|
551
|
+
* session with no `user` key (a `customSession` plugin that dropped it) is
|
|
552
|
+
* still a session and still gets projected, never misreported as anonymous.
|
|
553
|
+
* A listed field that can't be resolved from the session shape is omitted
|
|
554
|
+
* and warns once per field per process (see `warnUnresolvedSessionField`)
|
|
555
|
+
* instead of silently vanishing into an access-control function reading
|
|
556
|
+
* `undefined`.
|
|
359
557
|
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
558
|
+
* Errors from the underlying `auth.api.getSession()` call propagate rather
|
|
559
|
+
* than becoming `null` — collapsing a lookup failure (e.g. a session-store
|
|
560
|
+
* outage) into "anonymous" is indistinguishable from a mass sign-out under
|
|
561
|
+
* fail-closed access control, so the caller must see it.
|
|
562
|
+
*
|
|
563
|
+
* Not called by any generated code before this helper existed — apps used to
|
|
564
|
+
* hand-roll this same transform against `auth.api.getSession({ headers:
|
|
565
|
+
* await headers() })`. Exported as the single reusable implementation; pass
|
|
566
|
+
* the caller's request headers (e.g. Next.js `await headers()` in a Server
|
|
567
|
+
* Component/action) so a session cookie can actually be resolved.
|
|
365
568
|
*/
|
|
366
569
|
export async function getSessionFromAuth(
|
|
367
570
|
auth: ReturnType<typeof betterAuth>,
|
|
368
571
|
sessionFields: string[],
|
|
369
572
|
headers: Headers,
|
|
370
|
-
) {
|
|
371
|
-
|
|
372
|
-
const session = await auth.api.getSession({ headers })
|
|
573
|
+
): Promise<Session | null> {
|
|
574
|
+
const resolvedSession = await auth.api.getSession({ headers })
|
|
373
575
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
576
|
+
if (!resolvedSession) {
|
|
577
|
+
return null
|
|
578
|
+
}
|
|
377
579
|
|
|
378
|
-
|
|
379
|
-
|
|
580
|
+
const resolvedSessionRecord = resolvedSession as Record<string, unknown>
|
|
581
|
+
const result: Record<string, unknown> = {}
|
|
380
582
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
583
|
+
for (const field of sessionFields) {
|
|
584
|
+
const resolved = resolveSessionField(field, resolvedSessionRecord)
|
|
585
|
+
if (resolved.found) {
|
|
586
|
+
result[field] = resolved.value
|
|
587
|
+
} else {
|
|
588
|
+
warnUnresolvedSessionField(field, resolvedSessionRecord)
|
|
387
589
|
}
|
|
388
|
-
|
|
389
|
-
return result
|
|
390
|
-
} catch {
|
|
391
|
-
return null
|
|
392
590
|
}
|
|
591
|
+
|
|
592
|
+
return result
|
|
393
593
|
}
|
|
394
594
|
|
|
395
595
|
export type { BetterAuthOptions }
|
|
@@ -12,13 +12,13 @@ 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
|
|
@@ -29,7 +29,7 @@ type BetterAuthFieldAttribute = {
|
|
|
29
29
|
* Better Auth table schema structure
|
|
30
30
|
*/
|
|
31
31
|
type BetterAuthTableSchema = {
|
|
32
|
-
modelName
|
|
32
|
+
modelName?: string
|
|
33
33
|
fields: Record<string, BetterAuthFieldAttribute>
|
|
34
34
|
}
|
|
35
35
|
|
package/tests/server.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import type { BetterAuthOptions } from 'better-auth'
|
|
3
3
|
import type { NormalizedAuthConfig } from '../src/config/types.js'
|
|
4
4
|
import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
|
|
@@ -481,6 +481,108 @@ describe('buildBetterAuthOptions / createAuth parity', () => {
|
|
|
481
481
|
expect(betterAuthMock).toHaveBeenCalledTimes(1)
|
|
482
482
|
expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
|
|
483
483
|
})
|
|
484
|
+
|
|
485
|
+
it('createAuth with a plugin tuple constructs betterAuth with exactly what buildBetterAuthOptions returns for the same tuple', async () => {
|
|
486
|
+
const pluginA = { id: 'plugin-a' }
|
|
487
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
488
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
489
|
+
const context = makeContext()
|
|
490
|
+
|
|
491
|
+
const built = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
|
|
492
|
+
|
|
493
|
+
const auth = createAuth(opensaasConfig, context, [pluginA])
|
|
494
|
+
await auth.api.getSession({})
|
|
495
|
+
|
|
496
|
+
expect(betterAuthMock).toHaveBeenCalledTimes(1)
|
|
497
|
+
expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('createAuth rejects when its plugin tuple does not match the resolved betterAuthPlugins', async () => {
|
|
501
|
+
const pluginA = { id: 'plugin-a' }
|
|
502
|
+
const differentInstance = { id: 'plugin-a' }
|
|
503
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
504
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
505
|
+
const context = makeContext()
|
|
506
|
+
|
|
507
|
+
const auth = createAuth(opensaasConfig, context, [differentInstance])
|
|
508
|
+
|
|
509
|
+
await expect(auth.api.getSession({})).rejects.toThrow(
|
|
510
|
+
/does not match the plugin array resolved/,
|
|
511
|
+
)
|
|
512
|
+
expect(betterAuthMock).not.toHaveBeenCalled()
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
describe('buildBetterAuthOptions plugin-tuple argument', () => {
|
|
517
|
+
beforeEach(() => {
|
|
518
|
+
betterAuthMock.mockClear()
|
|
519
|
+
prismaAdapterMock.mockClear()
|
|
520
|
+
nextCookiesMock.mockClear()
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('rejects when the supplied tuple has a different length than the resolved betterAuthPlugins', async () => {
|
|
524
|
+
const pluginA = { id: 'plugin-a' }
|
|
525
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
526
|
+
|
|
527
|
+
await expect(
|
|
528
|
+
buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), []),
|
|
529
|
+
).rejects.toThrow(/has 0 plugin\(s\), but the plugin array resolved.*has 1/)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('rejects naming the mismatching index when a supplied plugin is not the same instance', async () => {
|
|
533
|
+
const pluginA = { id: 'plugin-a' }
|
|
534
|
+
const pluginB = { id: 'plugin-b' }
|
|
535
|
+
const differentInstance = { id: 'plugin-a' } // same id, different identity
|
|
536
|
+
|
|
537
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
|
|
538
|
+
|
|
539
|
+
await expect(
|
|
540
|
+
buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
|
|
541
|
+
differentInstance,
|
|
542
|
+
pluginB,
|
|
543
|
+
]),
|
|
544
|
+
).rejects.toThrow(/at index 0/)
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('rejects naming the mismatching index when the supplied order differs', async () => {
|
|
548
|
+
const pluginA = { id: 'plugin-a' }
|
|
549
|
+
const pluginB = { id: 'plugin-b' }
|
|
550
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
|
|
551
|
+
|
|
552
|
+
await expect(
|
|
553
|
+
buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [pluginB, pluginA]),
|
|
554
|
+
).rejects.toThrow(/at index 0/)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('does not throw when the supplied tuple is the exact same instances in the same order', async () => {
|
|
558
|
+
const pluginA = { id: 'plugin-a' }
|
|
559
|
+
const pluginB = { id: 'plugin-b' }
|
|
560
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
|
|
561
|
+
|
|
562
|
+
const config = await buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
|
|
563
|
+
pluginA,
|
|
564
|
+
pluginB,
|
|
565
|
+
])
|
|
566
|
+
|
|
567
|
+
expect(config.plugins).toEqual([pluginA, pluginB, { id: 'next-cookies' }])
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('appends exactly one nextCookies() plugin, last, whether or not a plugin tuple is supplied', async () => {
|
|
571
|
+
const pluginA = { id: 'plugin-a' }
|
|
572
|
+
const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
|
|
573
|
+
const opensaasConfig = makeOpensaasConfig(authConfig)
|
|
574
|
+
const context = makeContext()
|
|
575
|
+
|
|
576
|
+
const withoutArg = await buildBetterAuthOptions(opensaasConfig, context)
|
|
577
|
+
expect(nextCookiesMock).toHaveBeenCalledTimes(1)
|
|
578
|
+
expect(withoutArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
|
|
579
|
+
|
|
580
|
+
nextCookiesMock.mockClear()
|
|
581
|
+
|
|
582
|
+
const withArg = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
|
|
583
|
+
expect(nextCookiesMock).toHaveBeenCalledTimes(1)
|
|
584
|
+
expect(withArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
|
|
585
|
+
})
|
|
484
586
|
})
|
|
485
587
|
|
|
486
588
|
describe('getSessionFromAuth', () => {
|
|
@@ -504,14 +606,118 @@ describe('getSessionFromAuth', () => {
|
|
|
504
606
|
expect(result).toBeNull()
|
|
505
607
|
})
|
|
506
608
|
|
|
507
|
-
it('
|
|
609
|
+
it('propagates an error thrown by the underlying session lookup, distinguishable from no session', async () => {
|
|
508
610
|
const getSession = vi.fn(async () => {
|
|
509
611
|
throw new Error('boom')
|
|
510
612
|
})
|
|
511
613
|
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
512
614
|
|
|
513
|
-
|
|
615
|
+
await expect(getSessionFromAuth(auth, ['userId'], new Headers())).rejects.toThrow('boom')
|
|
616
|
+
})
|
|
514
617
|
|
|
515
|
-
|
|
618
|
+
it('resolves the documented happy path unchanged: fields on the user, userId from user.id', async () => {
|
|
619
|
+
const getSession = vi.fn(async () => ({
|
|
620
|
+
user: { id: 'user-1', email: 'a@b.com', name: 'Ada' },
|
|
621
|
+
}))
|
|
622
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
623
|
+
|
|
624
|
+
const result = await getSessionFromAuth(auth, ['userId', 'email', 'name'], new Headers())
|
|
625
|
+
|
|
626
|
+
expect(result).toEqual({ userId: 'user-1', email: 'a@b.com', name: 'Ada' })
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it('projects a customSession shape with no top-level user key instead of reporting anonymous', async () => {
|
|
630
|
+
// A customSession plugin can fully replace the resolved shape (e.g.
|
|
631
|
+
// nesting fields under a custom key) and drop the `user` object entirely
|
|
632
|
+
// — that must still be treated as "a session", not "no session".
|
|
633
|
+
const getSession = vi.fn(async () => ({
|
|
634
|
+
email: 'nested@example.com',
|
|
635
|
+
data: { role: 'admin' },
|
|
636
|
+
}))
|
|
637
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
638
|
+
|
|
639
|
+
const result = await getSessionFromAuth(auth, ['email'], new Headers())
|
|
640
|
+
|
|
641
|
+
expect(result).not.toBeNull()
|
|
642
|
+
expect(result).toEqual({ email: 'nested@example.com' })
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('resolves a field living on the session sub-object, not just the user', async () => {
|
|
646
|
+
const getSession = vi.fn(async () => ({
|
|
647
|
+
user: { id: 'user-1' },
|
|
648
|
+
session: { impersonatedBy: 'admin-1' },
|
|
649
|
+
}))
|
|
650
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
651
|
+
|
|
652
|
+
const result = await getSessionFromAuth(auth, ['userId', 'impersonatedBy'], new Headers())
|
|
653
|
+
|
|
654
|
+
expect(result).toEqual({ userId: 'user-1', impersonatedBy: 'admin-1' })
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
describe('resolution precedence', () => {
|
|
658
|
+
it('prefers a top-level key over the same name on user or session (deliberate collision)', async () => {
|
|
659
|
+
const getSession = vi.fn(async () => ({
|
|
660
|
+
role: 'top-level-role',
|
|
661
|
+
user: { role: 'user-role' },
|
|
662
|
+
session: { role: 'session-role' },
|
|
663
|
+
}))
|
|
664
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
665
|
+
|
|
666
|
+
const result = await getSessionFromAuth(auth, ['role'], new Headers())
|
|
667
|
+
|
|
668
|
+
expect(result).toEqual({ role: 'top-level-role' })
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('prefers the user object over the session sub-object when there is no top-level key', async () => {
|
|
672
|
+
const getSession = vi.fn(async () => ({
|
|
673
|
+
user: { role: 'user-role' },
|
|
674
|
+
session: { role: 'session-role' },
|
|
675
|
+
}))
|
|
676
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
677
|
+
|
|
678
|
+
const result = await getSessionFromAuth(auth, ['role'], new Headers())
|
|
679
|
+
|
|
680
|
+
expect(result).toEqual({ role: 'user-role' })
|
|
681
|
+
})
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
// The warn-once cache is module-level state, so these tests re-import the
|
|
685
|
+
// module fresh via vi.resetModules() — same pattern as the `select` no-op
|
|
686
|
+
// warning tests in packages/core/tests/context.test.ts.
|
|
687
|
+
describe('unresolved field warning', () => {
|
|
688
|
+
let warnSpy: ReturnType<typeof vi.spyOn>
|
|
689
|
+
let freshGetSessionFromAuth: typeof getSessionFromAuth
|
|
690
|
+
|
|
691
|
+
beforeEach(async () => {
|
|
692
|
+
vi.resetModules()
|
|
693
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
694
|
+
const mod = await import('../src/server/index.js')
|
|
695
|
+
freshGetSessionFromAuth = mod.getSessionFromAuth
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
afterEach(() => {
|
|
699
|
+
warnSpy.mockRestore()
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('omits an unresolvable field, warns once naming it, and does not throw', async () => {
|
|
703
|
+
const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
|
|
704
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
705
|
+
|
|
706
|
+
const result = await freshGetSessionFromAuth(auth, ['userId', 'nickname'], new Headers())
|
|
707
|
+
|
|
708
|
+
expect(result).toEqual({ userId: 'user-1' })
|
|
709
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
710
|
+
expect(warnSpy.mock.calls[0][0]).toContain('"nickname"')
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
it('does not warn again for the same field on a second call', async () => {
|
|
714
|
+
const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
|
|
715
|
+
const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
|
|
716
|
+
|
|
717
|
+
await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
|
|
718
|
+
await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
|
|
719
|
+
|
|
720
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
721
|
+
})
|
|
516
722
|
})
|
|
517
723
|
})
|