@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
@@ -36,6 +36,47 @@ describe('convertTableToList', () => {
36
36
  expect(listConfig.fields.score.defaultValue).toBe(0)
37
37
  })
38
38
 
39
+ it('should convert a number field with bigint: true to a bigInt field (issue #917)', () => {
40
+ const tableSchema = {
41
+ modelName: 'TestTable',
42
+ fields: {
43
+ lastRequest: { type: 'number', required: true, bigint: true },
44
+ },
45
+ }
46
+
47
+ const listConfig = convertTableToList('test_table', tableSchema)
48
+
49
+ expect(listConfig.fields.lastRequest.type).toBe('bigInt')
50
+ expect(listConfig.fields.lastRequest.validation?.isRequired).toBe(true)
51
+ })
52
+
53
+ it('should keep a number field with bigint: false as integer', () => {
54
+ const tableSchema = {
55
+ modelName: 'TestTable',
56
+ fields: {
57
+ age: { type: 'number', bigint: false },
58
+ },
59
+ }
60
+
61
+ const listConfig = convertTableToList('test_table', tableSchema)
62
+
63
+ expect(listConfig.fields.age.type).toBe('integer')
64
+ })
65
+
66
+ it('should pass defaultValue through on a bigint number field', () => {
67
+ const tableSchema = {
68
+ modelName: 'TestTable',
69
+ fields: {
70
+ lastRequest: { type: 'number', bigint: true, defaultValue: 0 },
71
+ },
72
+ }
73
+
74
+ const listConfig = convertTableToList('test_table', tableSchema)
75
+
76
+ expect(listConfig.fields.lastRequest.type).toBe('bigInt')
77
+ expect(listConfig.fields.lastRequest.defaultValue).toBe(0)
78
+ })
79
+
39
80
  it('should convert boolean fields', () => {
40
81
  const tableSchema = {
41
82
  modelName: 'TestTable',
@@ -298,6 +339,23 @@ describe('convertBetterAuthSchema', () => {
298
339
  expect(lists).not.toHaveProperty('User')
299
340
  })
300
341
 
342
+ it('should resolve a rateLimit table (case-insensitively) against the configured baseModelKeys remap (issue #909)', () => {
343
+ const schema = {
344
+ rateLimit: {
345
+ modelName: '',
346
+ fields: {
347
+ customField: { type: 'boolean' },
348
+ },
349
+ },
350
+ }
351
+
352
+ const lists = convertBetterAuthSchema(schema, { rateLimit: 'AuthRateLimit' })
353
+
354
+ expect(lists).toHaveProperty('AuthRateLimit')
355
+ expect(lists).not.toHaveProperty('RateLimit')
356
+ expect(lists.AuthRateLimit.fields).toHaveProperty('customField')
357
+ })
358
+
301
359
  it('should leave non-base tables unaffected by baseModelKeys', () => {
302
360
  const schema = {
303
361
  oauth_application: {
@@ -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'
@@ -459,6 +459,106 @@ describe('betterAuthOptions passthrough', () => {
459
459
 
460
460
  expect(config.user).toMatchObject({ modelName: 'CustomUser' })
461
461
  })
462
+
463
+ it('rejects betterAuthOptions.rateLimit.storage', async () => {
464
+ await expect(
465
+ buildBetterAuthOptions(
466
+ makeOpensaasConfig(
467
+ makeAuthConfig({ betterAuthOptions: { rateLimit: { storage: 'database' } } }),
468
+ ),
469
+ makeContext(),
470
+ ),
471
+ ).rejects.toThrow(/betterAuthOptions\.rateLimit\.storage/)
472
+ })
473
+
474
+ it('does not reject other betterAuthOptions.rateLimit keys (customRules/customStorage) and merges them', async () => {
475
+ const customRules = { '/sign-in/email': { window: 10, max: 3 } }
476
+ const config = await buildBetterAuthConfig(
477
+ makeAuthConfig({
478
+ rateLimit: { enabled: true, window: 60, max: 100 },
479
+ betterAuthOptions: {
480
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shape
481
+ rateLimit: { customRules } as any,
482
+ },
483
+ }),
484
+ )
485
+
486
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
487
+ const rateLimit = config.rateLimit as any
488
+ expect(rateLimit.customRules).toBe(customRules)
489
+ // The stack's own enabled/window/max survive the merge alongside customRules.
490
+ expect(rateLimit.enabled).toBe(true)
491
+ expect(rateLimit.window).toBe(60)
492
+ expect(rateLimit.max).toBe(100)
493
+ })
494
+ })
495
+
496
+ describe('rateLimit option forwarding (issue #909)', () => {
497
+ beforeEach(() => {
498
+ betterAuthMock.mockClear()
499
+ prismaAdapterMock.mockClear()
500
+ nextCookiesMock.mockClear()
501
+ })
502
+
503
+ it('forwards enabled/window/max with no storage key when rateLimit.storage is unset', async () => {
504
+ const config = await buildBetterAuthConfig(
505
+ makeAuthConfig({ rateLimit: { enabled: true, window: 60, max: 100 } }),
506
+ )
507
+
508
+ expect(config.rateLimit).toEqual({ enabled: true, window: 60, max: 100 })
509
+ })
510
+
511
+ it('forwards storage: "database" alongside enabled/window/max', async () => {
512
+ const config = await buildBetterAuthConfig(
513
+ makeAuthConfig({
514
+ rateLimit: { enabled: true, window: 60, max: 100, storage: 'database' },
515
+ models: {
516
+ user: { modelName: 'User', fields: {} },
517
+ session: { modelName: 'Session', fields: {} },
518
+ account: { modelName: 'Account', fields: {} },
519
+ verification: { modelName: 'Verification', fields: {} },
520
+ rateLimit: { modelName: 'RateLimit', fields: {} },
521
+ },
522
+ }),
523
+ )
524
+
525
+ expect(config.rateLimit).toMatchObject({
526
+ enabled: true,
527
+ window: 60,
528
+ max: 100,
529
+ storage: 'database',
530
+ modelName: 'RateLimit',
531
+ })
532
+ })
533
+
534
+ it('forwards a custom rateLimit modelName/fields to better-auth so the running instance matches the derived table', async () => {
535
+ const config = await buildBetterAuthConfig(
536
+ makeAuthConfig({
537
+ rateLimit: { enabled: true, storage: 'database' },
538
+ models: {
539
+ user: { modelName: 'User', fields: {} },
540
+ session: { modelName: 'Session', fields: {} },
541
+ account: { modelName: 'Account', fields: {} },
542
+ verification: { modelName: 'Verification', fields: {} },
543
+ rateLimit: { modelName: 'AuthRateLimit', fields: { key: 'limit_key' } },
544
+ },
545
+ }),
546
+ )
547
+
548
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
549
+ const rateLimit = config.rateLimit as any
550
+ expect(rateLimit.modelName).toBe('AuthRateLimit')
551
+ expect(rateLimit.fields).toEqual({ key: 'limit_key' })
552
+ })
553
+
554
+ it('does not forward modelName/fields when no rateLimit model was derived (storage unset)', async () => {
555
+ const config = await buildBetterAuthConfig(makeAuthConfig({ rateLimit: { enabled: true } }))
556
+
557
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- narrow test-only access
558
+ const rateLimit = config.rateLimit as any
559
+ expect(rateLimit.modelName).toBeUndefined()
560
+ expect(rateLimit.fields).toBeUndefined()
561
+ })
462
562
  })
463
563
 
464
564
  describe('buildBetterAuthOptions / createAuth parity', () => {
@@ -481,6 +581,108 @@ describe('buildBetterAuthOptions / createAuth parity', () => {
481
581
  expect(betterAuthMock).toHaveBeenCalledTimes(1)
482
582
  expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
483
583
  })
584
+
585
+ it('createAuth with a plugin tuple constructs betterAuth with exactly what buildBetterAuthOptions returns for the same tuple', async () => {
586
+ const pluginA = { id: 'plugin-a' }
587
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
588
+ const opensaasConfig = makeOpensaasConfig(authConfig)
589
+ const context = makeContext()
590
+
591
+ const built = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
592
+
593
+ const auth = createAuth(opensaasConfig, context, [pluginA])
594
+ await auth.api.getSession({})
595
+
596
+ expect(betterAuthMock).toHaveBeenCalledTimes(1)
597
+ expect(betterAuthMock.mock.calls[0][0]).toEqual(built)
598
+ })
599
+
600
+ it('createAuth rejects when its plugin tuple does not match the resolved betterAuthPlugins', async () => {
601
+ const pluginA = { id: 'plugin-a' }
602
+ const differentInstance = { id: 'plugin-a' }
603
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
604
+ const opensaasConfig = makeOpensaasConfig(authConfig)
605
+ const context = makeContext()
606
+
607
+ const auth = createAuth(opensaasConfig, context, [differentInstance])
608
+
609
+ await expect(auth.api.getSession({})).rejects.toThrow(
610
+ /does not match the plugin array resolved/,
611
+ )
612
+ expect(betterAuthMock).not.toHaveBeenCalled()
613
+ })
614
+ })
615
+
616
+ describe('buildBetterAuthOptions plugin-tuple argument', () => {
617
+ beforeEach(() => {
618
+ betterAuthMock.mockClear()
619
+ prismaAdapterMock.mockClear()
620
+ nextCookiesMock.mockClear()
621
+ })
622
+
623
+ it('rejects when the supplied tuple has a different length than the resolved betterAuthPlugins', async () => {
624
+ const pluginA = { id: 'plugin-a' }
625
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
626
+
627
+ await expect(
628
+ buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), []),
629
+ ).rejects.toThrow(/has 0 plugin\(s\), but the plugin array resolved.*has 1/)
630
+ })
631
+
632
+ it('rejects naming the mismatching index when a supplied plugin is not the same instance', async () => {
633
+ const pluginA = { id: 'plugin-a' }
634
+ const pluginB = { id: 'plugin-b' }
635
+ const differentInstance = { id: 'plugin-a' } // same id, different identity
636
+
637
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
638
+
639
+ await expect(
640
+ buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
641
+ differentInstance,
642
+ pluginB,
643
+ ]),
644
+ ).rejects.toThrow(/at index 0/)
645
+ })
646
+
647
+ it('rejects naming the mismatching index when the supplied order differs', async () => {
648
+ const pluginA = { id: 'plugin-a' }
649
+ const pluginB = { id: 'plugin-b' }
650
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
651
+
652
+ await expect(
653
+ buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [pluginB, pluginA]),
654
+ ).rejects.toThrow(/at index 0/)
655
+ })
656
+
657
+ it('does not throw when the supplied tuple is the exact same instances in the same order', async () => {
658
+ const pluginA = { id: 'plugin-a' }
659
+ const pluginB = { id: 'plugin-b' }
660
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA, pluginB] })
661
+
662
+ const config = await buildBetterAuthOptions(makeOpensaasConfig(authConfig), makeContext(), [
663
+ pluginA,
664
+ pluginB,
665
+ ])
666
+
667
+ expect(config.plugins).toEqual([pluginA, pluginB, { id: 'next-cookies' }])
668
+ })
669
+
670
+ it('appends exactly one nextCookies() plugin, last, whether or not a plugin tuple is supplied', async () => {
671
+ const pluginA = { id: 'plugin-a' }
672
+ const authConfig = makeAuthConfig({ betterAuthPlugins: [pluginA] })
673
+ const opensaasConfig = makeOpensaasConfig(authConfig)
674
+ const context = makeContext()
675
+
676
+ const withoutArg = await buildBetterAuthOptions(opensaasConfig, context)
677
+ expect(nextCookiesMock).toHaveBeenCalledTimes(1)
678
+ expect(withoutArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
679
+
680
+ nextCookiesMock.mockClear()
681
+
682
+ const withArg = await buildBetterAuthOptions(opensaasConfig, context, [pluginA])
683
+ expect(nextCookiesMock).toHaveBeenCalledTimes(1)
684
+ expect(withArg.plugins).toEqual([pluginA, { id: 'next-cookies' }])
685
+ })
484
686
  })
485
687
 
486
688
  describe('getSessionFromAuth', () => {
@@ -504,14 +706,118 @@ describe('getSessionFromAuth', () => {
504
706
  expect(result).toBeNull()
505
707
  })
506
708
 
507
- it('returns null when auth.api.getSession throws', async () => {
709
+ it('propagates an error thrown by the underlying session lookup, distinguishable from no session', async () => {
508
710
  const getSession = vi.fn(async () => {
509
711
  throw new Error('boom')
510
712
  })
511
713
  const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
512
714
 
513
- const result = await getSessionFromAuth(auth, ['userId'], new Headers())
715
+ await expect(getSessionFromAuth(auth, ['userId'], new Headers())).rejects.toThrow('boom')
716
+ })
514
717
 
515
- expect(result).toBeNull()
718
+ it('resolves the documented happy path unchanged: fields on the user, userId from user.id', async () => {
719
+ const getSession = vi.fn(async () => ({
720
+ user: { id: 'user-1', email: 'a@b.com', name: 'Ada' },
721
+ }))
722
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
723
+
724
+ const result = await getSessionFromAuth(auth, ['userId', 'email', 'name'], new Headers())
725
+
726
+ expect(result).toEqual({ userId: 'user-1', email: 'a@b.com', name: 'Ada' })
727
+ })
728
+
729
+ it('projects a customSession shape with no top-level user key instead of reporting anonymous', async () => {
730
+ // A customSession plugin can fully replace the resolved shape (e.g.
731
+ // nesting fields under a custom key) and drop the `user` object entirely
732
+ // — that must still be treated as "a session", not "no session".
733
+ const getSession = vi.fn(async () => ({
734
+ email: 'nested@example.com',
735
+ data: { role: 'admin' },
736
+ }))
737
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
738
+
739
+ const result = await getSessionFromAuth(auth, ['email'], new Headers())
740
+
741
+ expect(result).not.toBeNull()
742
+ expect(result).toEqual({ email: 'nested@example.com' })
743
+ })
744
+
745
+ it('resolves a field living on the session sub-object, not just the user', async () => {
746
+ const getSession = vi.fn(async () => ({
747
+ user: { id: 'user-1' },
748
+ session: { impersonatedBy: 'admin-1' },
749
+ }))
750
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
751
+
752
+ const result = await getSessionFromAuth(auth, ['userId', 'impersonatedBy'], new Headers())
753
+
754
+ expect(result).toEqual({ userId: 'user-1', impersonatedBy: 'admin-1' })
755
+ })
756
+
757
+ describe('resolution precedence', () => {
758
+ it('prefers a top-level key over the same name on user or session (deliberate collision)', async () => {
759
+ const getSession = vi.fn(async () => ({
760
+ role: 'top-level-role',
761
+ user: { role: 'user-role' },
762
+ session: { role: 'session-role' },
763
+ }))
764
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
765
+
766
+ const result = await getSessionFromAuth(auth, ['role'], new Headers())
767
+
768
+ expect(result).toEqual({ role: 'top-level-role' })
769
+ })
770
+
771
+ it('prefers the user object over the session sub-object when there is no top-level key', async () => {
772
+ const getSession = vi.fn(async () => ({
773
+ user: { role: 'user-role' },
774
+ session: { role: 'session-role' },
775
+ }))
776
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
777
+
778
+ const result = await getSessionFromAuth(auth, ['role'], new Headers())
779
+
780
+ expect(result).toEqual({ role: 'user-role' })
781
+ })
782
+ })
783
+
784
+ // The warn-once cache is module-level state, so these tests re-import the
785
+ // module fresh via vi.resetModules() — same pattern as the `select` no-op
786
+ // warning tests in packages/core/tests/context.test.ts.
787
+ describe('unresolved field warning', () => {
788
+ let warnSpy: ReturnType<typeof vi.spyOn>
789
+ let freshGetSessionFromAuth: typeof getSessionFromAuth
790
+
791
+ beforeEach(async () => {
792
+ vi.resetModules()
793
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
794
+ const mod = await import('../src/server/index.js')
795
+ freshGetSessionFromAuth = mod.getSessionFromAuth
796
+ })
797
+
798
+ afterEach(() => {
799
+ warnSpy.mockRestore()
800
+ })
801
+
802
+ it('omits an unresolvable field, warns once naming it, and does not throw', async () => {
803
+ const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
804
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
805
+
806
+ const result = await freshGetSessionFromAuth(auth, ['userId', 'nickname'], new Headers())
807
+
808
+ expect(result).toEqual({ userId: 'user-1' })
809
+ expect(warnSpy).toHaveBeenCalledTimes(1)
810
+ expect(warnSpy.mock.calls[0][0]).toContain('"nickname"')
811
+ })
812
+
813
+ it('does not warn again for the same field on a second call', async () => {
814
+ const getSession = vi.fn(async () => ({ user: { id: 'user-1' } }))
815
+ const auth = { api: { getSession } } as unknown as Parameters<typeof getSessionFromAuth>[0]
816
+
817
+ await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
818
+ await freshGetSessionFromAuth(auth, ['nickname'], new Headers())
819
+
820
+ expect(warnSpy).toHaveBeenCalledTimes(1)
821
+ })
516
822
  })
517
823
  })