@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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +60 -0
  3. package/CLAUDE.md +50 -3
  4. package/dist/config/adopt-better-auth-tables.d.ts +23 -3
  5. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  6. package/dist/config/adopt-better-auth-tables.js +7 -2
  7. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  8. package/dist/config/derive-auth-lists.d.ts +6 -1
  9. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  10. package/dist/config/derive-auth-lists.js +63 -16
  11. package/dist/config/derive-auth-lists.js.map +1 -1
  12. package/dist/config/index.d.ts.map +1 -1
  13. package/dist/config/index.js +12 -5
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/config/plugin.d.ts.map +1 -1
  16. package/dist/config/plugin.js +7 -2
  17. package/dist/config/plugin.js.map +1 -1
  18. package/dist/config/types.d.ts +62 -7
  19. package/dist/config/types.d.ts.map +1 -1
  20. package/dist/server/build-better-auth-options.test.d.ts +2 -0
  21. package/dist/server/build-better-auth-options.test.d.ts.map +1 -0
  22. package/dist/server/build-better-auth-options.test.js +29 -0
  23. package/dist/server/build-better-auth-options.test.js.map +1 -0
  24. package/dist/server/get-session-from-auth.test.d.ts +2 -0
  25. package/dist/server/get-session-from-auth.test.d.ts.map +1 -0
  26. package/dist/server/get-session-from-auth.test.js +25 -0
  27. package/dist/server/get-session-from-auth.test.js.map +1 -0
  28. package/dist/server/index.d.ts +108 -15
  29. package/dist/server/index.d.ts.map +1 -1
  30. package/dist/server/index.js +151 -63
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/schema-converter.d.ts +15 -6
  33. package/dist/server/schema-converter.d.ts.map +1 -1
  34. package/dist/server/schema-converter.js +14 -2
  35. package/dist/server/schema-converter.js.map +1 -1
  36. package/package.json +5 -5
  37. package/src/config/adopt-better-auth-tables.ts +31 -4
  38. package/src/config/derive-auth-lists.ts +82 -21
  39. package/src/config/index.ts +18 -5
  40. package/src/config/plugin.ts +7 -2
  41. package/src/config/types.ts +63 -9
  42. package/src/server/build-better-auth-options.test.ts +59 -0
  43. package/src/server/get-session-from-auth.test.ts +52 -0
  44. package/src/server/index.ts +273 -41
  45. package/src/server/schema-converter.ts +29 -8
  46. package/tests/adopt-better-auth-tables.test.ts +73 -0
  47. package/tests/config.test.ts +161 -0
  48. package/tests/derive-auth-lists.test.ts +104 -0
  49. package/tests/generated-fk-shape.test.ts +81 -0
  50. package/tests/plugin-schema-placement.test.ts +39 -0
  51. package/tests/rate-limit-e2e.test.ts +239 -0
  52. package/tests/schema-converter.test.ts +58 -0
  53. package/tests/server.test.ts +310 -4
  54. package/tsconfig.tsbuildinfo +1 -1
  55. package/vitest.config.ts +7 -1
@@ -27,7 +27,14 @@
27
27
  */
28
28
 
29
29
  import { list } from '@opensaas/stack-core'
30
- import { text, timestamp, checkbox, relationship } from '@opensaas/stack-core/fields'
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
- * Always opts the list into auto-timestamps (`timestamps: true`). better-auth's
59
- * adapter writes `createdAt`/`updatedAt` on every auth row and the schema
60
- * converter returns `null` for those columns (it assumes the generator injects
61
- * them). Now that auto-timestamps are OFF by default (ADR-0004), each derived
62
- * Auth list must opt back in so the generated models keep those columns and
63
- * better-auth keeps working.
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 we emit only `timestamps: true`,
72
- * leaving the default `User`/`Session`/... table/schema output unchanged.
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(model: NormalizedAuthModelConfig): {
75
- timestamps: true
76
- map?: string
77
- schema?: string
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 four Auth list configs keyed by those keys
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
  }
@@ -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
- * `defaultSchema` is the plugin-level `schema` applied to every model unless a
56
- * per-model `schema` override is given.
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
- return {
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
  /**
@@ -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' && 'schema' in plugin) {
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)
@@ -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
- * This determines what data is available in access control functions
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth plugin types are not exposed, must use any
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth plugin types are not exposed, must use any
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
+ })