@opensaas/stack-auth 0.39.2 → 0.41.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 (104) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +153 -0
  3. package/CLAUDE.md +140 -22
  4. package/dist/client/index.d.ts +0 -16
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/config/adopt-better-auth-tables.d.ts +4 -49
  7. package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
  8. package/dist/config/adopt-better-auth-tables.js +4 -51
  9. package/dist/config/adopt-better-auth-tables.js.map +1 -1
  10. package/dist/config/derive-auth-lists.d.ts +42 -37
  11. package/dist/config/derive-auth-lists.d.ts.map +1 -1
  12. package/dist/config/derive-auth-lists.js +579 -223
  13. package/dist/config/derive-auth-lists.js.map +1 -1
  14. package/dist/config/index.d.ts +0 -3
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +4 -34
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/config/plugin.d.ts.map +1 -1
  19. package/dist/config/plugin.js +20 -90
  20. package/dist/config/plugin.js.map +1 -1
  21. package/dist/config/types.d.ts +89 -67
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/index.d.ts +1 -9
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -13
  26. package/dist/index.js.map +1 -1
  27. package/dist/lists/index.d.ts +9 -5
  28. package/dist/lists/index.d.ts.map +1 -1
  29. package/dist/lists/index.js +9 -6
  30. package/dist/lists/index.js.map +1 -1
  31. package/dist/mcp/better-auth.d.ts +6 -19
  32. package/dist/mcp/better-auth.d.ts.map +1 -1
  33. package/dist/mcp/better-auth.js +6 -20
  34. package/dist/mcp/better-auth.js.map +1 -1
  35. package/dist/plugins/index.d.ts +4 -4
  36. package/dist/plugins/index.d.ts.map +1 -1
  37. package/dist/plugins/index.js +4 -4
  38. package/dist/plugins/index.js.map +1 -1
  39. package/dist/runtime/types.d.ts +2 -9
  40. package/dist/runtime/types.d.ts.map +1 -1
  41. package/dist/runtime/types.js +0 -4
  42. package/dist/runtime/types.js.map +1 -1
  43. package/dist/server/index.d.ts +5 -12
  44. package/dist/server/index.d.ts.map +1 -1
  45. package/dist/server/index.js +14 -62
  46. package/dist/server/index.js.map +1 -1
  47. package/dist/ui/components/ForgotPasswordForm.d.ts +0 -3
  48. package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -1
  49. package/dist/ui/components/ForgotPasswordForm.js +0 -3
  50. package/dist/ui/components/ForgotPasswordForm.js.map +1 -1
  51. package/dist/ui/components/ResetPasswordForm.d.ts +0 -3
  52. package/dist/ui/components/ResetPasswordForm.d.ts.map +1 -1
  53. package/dist/ui/components/ResetPasswordForm.js +0 -5
  54. package/dist/ui/components/ResetPasswordForm.js.map +1 -1
  55. package/dist/ui/components/SignInForm.d.ts +0 -3
  56. package/dist/ui/components/SignInForm.d.ts.map +1 -1
  57. package/dist/ui/components/SignInForm.js +0 -4
  58. package/dist/ui/components/SignInForm.js.map +1 -1
  59. package/dist/ui/components/SignUpForm.d.ts +0 -3
  60. package/dist/ui/components/SignUpForm.d.ts.map +1 -1
  61. package/dist/ui/components/SignUpForm.js +0 -5
  62. package/dist/ui/components/SignUpForm.js.map +1 -1
  63. package/dist/ui/lib/clean-error-message.d.ts +3 -6
  64. package/dist/ui/lib/clean-error-message.d.ts.map +1 -1
  65. package/dist/ui/lib/clean-error-message.js +3 -6
  66. package/dist/ui/lib/clean-error-message.js.map +1 -1
  67. package/dist/ui/types.d.ts +2 -15
  68. package/dist/ui/types.d.ts.map +1 -1
  69. package/dist/ui/types.js +2 -7
  70. package/dist/ui/types.js.map +1 -1
  71. package/package.json +12 -5
  72. package/src/client/index.ts +0 -17
  73. package/src/config/adopt-better-auth-tables.ts +4 -51
  74. package/src/config/derive-auth-lists.ts +685 -252
  75. package/src/config/index.ts +4 -34
  76. package/src/config/plugin.ts +21 -91
  77. package/src/config/types.ts +90 -67
  78. package/src/index.ts +3 -14
  79. package/src/lists/index.ts +17 -5
  80. package/src/mcp/better-auth.ts +6 -25
  81. package/src/plugins/index.ts +4 -5
  82. package/src/runtime/types.ts +2 -10
  83. package/src/server/index.ts +17 -70
  84. package/src/ui/components/ForgotPasswordForm.tsx +0 -3
  85. package/src/ui/components/ResetPasswordForm.tsx +0 -5
  86. package/src/ui/components/SignInForm.tsx +0 -4
  87. package/src/ui/components/SignUpForm.tsx +0 -5
  88. package/src/ui/lib/clean-error-message.ts +3 -6
  89. package/src/ui/types.ts +2 -15
  90. package/tests/auth-lists-drift.test.ts +480 -0
  91. package/tests/config.test.ts +3 -0
  92. package/tests/credential-field-read-deny-e2e.test.ts +295 -0
  93. package/tests/derive-auth-lists.test.ts +263 -6
  94. package/tests/generated-fk-shape.test.ts +432 -9
  95. package/tests/mcp-oauth-cascade-e2e.test.ts +267 -0
  96. package/tests/plugin-derived-keys.test.ts +39 -0
  97. package/tests/plugin-table-derivation.test.ts +270 -0
  98. package/tsconfig.tsbuildinfo +1 -1
  99. package/dist/server/schema-converter.d.ts +0 -81
  100. package/dist/server/schema-converter.d.ts.map +0 -1
  101. package/dist/server/schema-converter.js +0 -157
  102. package/dist/server/schema-converter.js.map +0 -1
  103. package/src/server/schema-converter.ts +0 -243
  104. package/tests/schema-converter.test.ts +0 -399
@@ -0,0 +1,267 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+ import { existsSync } from 'node:fs'
5
+ import fsp from 'node:fs/promises'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import { pathToFileURL } from 'node:url'
9
+ import { createAuth } from '../src/server/index.js'
10
+ import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core'
11
+
12
+ /**
13
+ * Live end-to-end proof that consolidating the plugin-table derivation onto
14
+ * `deriveAuthLists` (issue #992) actually closes the orphaned-row defect
15
+ * #992's own triage confirmed: generates a real Prisma schema (MCP plugin
16
+ * enabled, so the OAuth tables are derived with real foreign keys), pushes
17
+ * it to a real SQLite database, signs up a real user through a real
18
+ * `betterAuth()` instance (via `createAuth()`, the package's own source, not
19
+ * a stale build), attaches OAuth rows to that user via the raw Prisma client
20
+ * (the same path better-auth's own adapter writes through — these lists ship
21
+ * closed per ADR-0013), deletes the user through better-auth's own
22
+ * `/delete-user` endpoint (the exact `internalAdapter.deleteUser` path
23
+ * #992's triage traced), and asserts the database cascade removed every
24
+ * OAuth row rather than leaving them orphaned.
25
+ *
26
+ * Follows the same opt-in/offline-toolchain pattern as `rate-limit-e2e.test.ts`
27
+ * (see ADR-0002) — kept out of the fast unit lane, run only in the `e2e` CI
28
+ * job where `pnpm install && pnpm build` have already run.
29
+ */
30
+
31
+ const run = promisify(execFile)
32
+ const here = path.dirname(new URL(import.meta.url).pathname)
33
+ const repoRoot = path.resolve(here, '../../..')
34
+
35
+ const opensaasCli = path.join(repoRoot, 'packages/cli/dist/index.js')
36
+ const coreDist = path.join(repoRoot, 'packages/core/dist')
37
+ const authDist = path.join(repoRoot, 'packages/auth/dist')
38
+ const toolchainNodeModules = path.join(repoRoot, 'examples/starter-auth/node_modules')
39
+
40
+ const guardEnabled = process.env.RUN_MCP_OAUTH_CASCADE_E2E === '1'
41
+
42
+ const prerequisitesPresent =
43
+ guardEnabled &&
44
+ existsSync(opensaasCli) &&
45
+ existsSync(coreDist) &&
46
+ existsSync(authDist) &&
47
+ existsSync(path.join(toolchainNodeModules, '.bin', 'opensaas')) &&
48
+ existsSync(path.join(toolchainNodeModules, '.bin', 'prisma')) &&
49
+ existsSync(path.join(toolchainNodeModules, '@prisma', 'adapter-better-sqlite3'))
50
+
51
+ /** The temp project's `opensaas.config.ts`: sqlite + the MCP plugin + self-service account deletion enabled. */
52
+ function configSource(): string {
53
+ return `import { config } from '@opensaas/stack-core'
54
+ import { authPlugin } from '@opensaas/stack-auth'
55
+ import { mcp } from '@opensaas/stack-auth/plugins'
56
+ import { jwt } from 'better-auth/plugins'
57
+ import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
58
+
59
+ export default config({
60
+ plugins: [
61
+ authPlugin({
62
+ emailAndPassword: { enabled: true },
63
+ // better-auth 1.7's mcp() is built on the OAuth Provider, which issues
64
+ // JWT-based access tokens and requires better-auth's own jwt() plugin
65
+ // to be registered alongside it (throws BetterAuthError: jwt_config
66
+ // otherwise) — see @better-auth/mcp's own usage example.
67
+ betterAuthPlugins: [
68
+ jwt(),
69
+ mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'http://localhost:3000/api/mcp' }),
70
+ ],
71
+ // Not modelled by AuthConfig — passed through verbatim so /delete-user
72
+ // deletes immediately instead of requiring an email-verification
73
+ // round trip (see packages/auth/CLAUDE.md, "betterAuthOptions").
74
+ // baseURL is explicit rather than relying on the BETTER_AUTH_URL env
75
+ // var: better-auth 1.7's OAuth Provider needs a resolvable URL to build
76
+ // its own endpoint/issuer URLs during init, and this test constructs
77
+ // \`auth\` in-process via createAuth() (not through the CLI subprocess
78
+ // that does see BETTER_AUTH_URL), so the auto-detected origin would be
79
+ // undefined here.
80
+ betterAuthOptions: {
81
+ baseURL: 'http://localhost:3000',
82
+ user: { deleteUser: { enabled: true } },
83
+ },
84
+ }),
85
+ ],
86
+ db: {
87
+ provider: 'sqlite',
88
+ prismaClientConstructor: (PrismaClient) => {
89
+ const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || './dev.db' })
90
+ return new PrismaClient({ adapter })
91
+ },
92
+ },
93
+ lists: {},
94
+ })
95
+ `
96
+ }
97
+
98
+ /** Scaffold a fresh temp project with the MCP plugin enabled and push its schema. Returns the project dir. */
99
+ async function setupProject(): Promise<string> {
100
+ const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'opensaas-mcp-cascade-e2e-'))
101
+ const dir = path.join(tmpRoot, 'project')
102
+ await fsp.mkdir(dir, { recursive: true })
103
+
104
+ await fsp.writeFile(path.join(dir, 'opensaas.config.ts'), configSource())
105
+ await fsp.writeFile(
106
+ path.join(dir, 'package.json'),
107
+ JSON.stringify(
108
+ { name: 'mcp-cascade-e2e-project', version: '0.0.0', private: true, type: 'module' },
109
+ null,
110
+ 2,
111
+ ),
112
+ )
113
+ await fsp.writeFile(path.join(dir, '.env'), 'DATABASE_URL=file:./dev.db\n')
114
+ await fsp.symlink(toolchainNodeModules, path.join(dir, 'node_modules'))
115
+
116
+ const env = {
117
+ ...process.env,
118
+ DATABASE_URL: 'file:./dev.db',
119
+ BETTER_AUTH_SECRET: 'e2e-test-secret-not-for-production-0000000000',
120
+ BETTER_AUTH_URL: 'http://localhost:3000',
121
+ }
122
+ const binDir = path.join(dir, 'node_modules', '.bin')
123
+ await run(path.join(binDir, 'opensaas'), ['generate'], { cwd: dir, env })
124
+ await run(path.join(binDir, 'prisma'), ['db', 'push'], { cwd: dir, env })
125
+
126
+ return dir
127
+ }
128
+
129
+ /** Import the temp project's config + generated context and build a real auth instance via `createAuth()`. */
130
+ async function createAuthInstanceForProject(dir: string) {
131
+ process.env.DATABASE_URL = `file:${path.join(dir, 'dev.db')}`
132
+
133
+ // `@vite-ignore` suppresses Vite's static analysis of this computed,
134
+ // external (outside the package root) specifier.
135
+ const config = (
136
+ (await import(/* @vite-ignore */ pathToFileURL(path.join(dir, 'opensaas.config.ts')).href)) as {
137
+ default: OpenSaasConfig | Promise<OpenSaasConfig>
138
+ }
139
+ ).default
140
+ const { rawOpensaasContext } = (await import(
141
+ /* @vite-ignore */ pathToFileURL(path.join(dir, '.opensaas/context.ts')).href
142
+ )) as { rawOpensaasContext: Promise<AccessContext> }
143
+
144
+ const resolvedContext = await rawOpensaasContext
145
+ return { auth: createAuth(config, resolvedContext), context: resolvedContext }
146
+ }
147
+
148
+ async function cleanupProject(dir: string): Promise<void> {
149
+ // See rate-limit-e2e.test.ts for why this global must be cleared between projects.
150
+ delete (globalThis as { prisma?: unknown }).prisma
151
+
152
+ const linkPath = path.join(dir, 'node_modules')
153
+ if (existsSync(linkPath)) {
154
+ await fsp.unlink(linkPath)
155
+ }
156
+ await fsp.rm(path.dirname(dir), { recursive: true, force: true })
157
+ }
158
+
159
+ describe.skipIf(!prerequisitesPresent)(
160
+ 'MCP plugin OAuth tables cascade on user deletion — live end-to-end (issue #992)',
161
+ () => {
162
+ it('deleting a user via better-auth’s own /delete-user removes their OAuth clients, access tokens and consents; another user’s rows survive', async () => {
163
+ const dir = await setupProject()
164
+ try {
165
+ const { auth, context } = await createAuthInstanceForProject(dir)
166
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the temp project's own generated Prisma Client, not a type this package can import
167
+ const prisma = context.prisma as any
168
+
169
+ const { headers: deletedHeaders } = await auth.api.signUpEmail({
170
+ body: { email: 'deleted@example.com', password: 'password1234', name: 'Deleted User' },
171
+ returnHeaders: true,
172
+ })
173
+ const deletedCookie = deletedHeaders.get('set-cookie') ?? ''
174
+ const deletedSession = await auth.api.getSession({
175
+ headers: new Headers({ cookie: deletedCookie }),
176
+ })
177
+ const deletedUserId = deletedSession!.user.id
178
+
179
+ const { headers: survivorHeaders } = await auth.api.signUpEmail({
180
+ body: { email: 'survivor@example.com', password: 'password1234', name: 'Survivor User' },
181
+ returnHeaders: true,
182
+ })
183
+ const survivorSession = await auth.api.getSession({
184
+ headers: new Headers({ cookie: survivorHeaders.get('set-cookie') ?? '' }),
185
+ })
186
+ const survivorUserId = survivorSession!.user.id
187
+
188
+ // OAuth rows are created through the raw Prisma client — these lists
189
+ // ship closed (ADR-0013), and this is the same path better-auth's own
190
+ // OAuth flows write through in production. better-auth 1.7's OAuth
191
+ // Provider (issue #986) splits what the pre-1.7 MCP plugin modelled
192
+ // as a single "application" with embedded access/refresh tokens into
193
+ // oauthClient (the registered client) plus a standalone
194
+ // oauthAccessToken (no more combined access+refresh token row).
195
+ for (const [suffix, userId] of [
196
+ ['deleted', deletedUserId],
197
+ ['survivor', survivorUserId],
198
+ ]) {
199
+ await prisma.oauthClient.create({
200
+ data: {
201
+ name: `App ${suffix}`,
202
+ clientId: `client-${suffix}`,
203
+ redirectUris: 'http://localhost/callback',
204
+ userId,
205
+ },
206
+ })
207
+ await prisma.oauthAccessToken.create({
208
+ data: {
209
+ token: `access-${suffix}`,
210
+ clientId: `client-${suffix}`,
211
+ scopes: 'openid',
212
+ expiresAt: new Date(Date.now() + 3_600_000),
213
+ // oauthAccessToken declares createdAt but not updatedAt
214
+ // upstream, so it derives as an ordinary required column with
215
+ // no DB-level default (see hasSymmetricTimestamps in
216
+ // derive-auth-lists.ts) — better-auth's own adapter always
217
+ // supplies it explicitly, so this raw-Prisma seed must too.
218
+ createdAt: new Date(),
219
+ userId,
220
+ },
221
+ })
222
+ await prisma.oauthConsent.create({
223
+ data: { clientId: `client-${suffix}`, scopes: 'openid', userId },
224
+ })
225
+ }
226
+
227
+ expect(await prisma.oauthClient.count()).toBe(2)
228
+ expect(await prisma.oauthAccessToken.count()).toBe(2)
229
+ expect(await prisma.oauthConsent.count()).toBe(2)
230
+
231
+ const deleteResult = await auth.api.deleteUser({
232
+ body: {},
233
+ headers: new Headers({ cookie: deletedCookie }),
234
+ })
235
+ expect(deleteResult).toEqual({ success: true, message: 'User deleted' })
236
+
237
+ expect(await prisma.user.findUnique({ where: { id: deletedUserId } })).toBeNull()
238
+
239
+ // No orphans: every row belonging to the deleted user is gone via the
240
+ // database cascade, not just the user row itself.
241
+ expect(await prisma.oauthClient.findFirst({ where: { userId: deletedUserId } })).toBeNull()
242
+ expect(
243
+ await prisma.oauthAccessToken.findFirst({ where: { userId: deletedUserId } }),
244
+ ).toBeNull()
245
+ expect(await prisma.oauthConsent.findFirst({ where: { userId: deletedUserId } })).toBeNull()
246
+
247
+ // The other user's rows are untouched — the cascade is scoped to the
248
+ // deleted user's own foreign key, not a wholesale table wipe.
249
+ expect(await prisma.user.findUnique({ where: { id: survivorUserId } })).not.toBeNull()
250
+ expect(await prisma.oauthClient.count()).toBe(1)
251
+ expect(await prisma.oauthAccessToken.count()).toBe(1)
252
+ expect(await prisma.oauthConsent.count()).toBe(1)
253
+
254
+ await context.prisma.$disconnect()
255
+ } finally {
256
+ await cleanupProject(dir)
257
+ }
258
+ }, 120_000)
259
+ },
260
+ )
261
+
262
+ // Surface, in a normal unit run, why this guard was skipped.
263
+ describe.runIf(!prerequisitesPresent)('MCP OAuth cascade e2e (skipped)', () => {
264
+ it('runs only in the e2e job (set RUN_MCP_OAUTH_CASCADE_E2E=1 after install + build)', () => {
265
+ expect(prerequisitesPresent).toBe(false)
266
+ })
267
+ })
@@ -209,6 +209,45 @@ describe('authPlugin - add-vs-extend with derived keys', () => {
209
209
  // ...or its access config.
210
210
  expect(authUser.access?.operation?.query).toBe(userQuery)
211
211
  })
212
+
213
+ it('keeps a plugin-table credential field read-denied even when the app redeclares that list and field (issue #1014)', async () => {
214
+ // An app opening a plugin-derived list the documented way (declaring the
215
+ // list itself under the derived key) merges in via extendList — field
216
+ // additions win over the app's own field of the same key (core's
217
+ // extendList spreads `extension.fields` last), so the derived,
218
+ // read-denied clientSecret must survive even though the app also
219
+ // declares a `clientSecret` field on its own `OauthClient` list.
220
+ const mcpBetterAuthPlugin = {
221
+ id: 'test-mcp',
222
+ schema: {
223
+ oauthClient: {
224
+ fields: {
225
+ clientSecret: { type: 'string' as const, required: false },
226
+ name: { type: 'string' as const, required: false },
227
+ },
228
+ },
229
+ },
230
+ }
231
+
232
+ const result = await config({
233
+ db: { provider: 'sqlite' },
234
+ plugins: [authPlugin({ betterAuthPlugins: [mcpBetterAuthPlugin] })],
235
+ lists: {
236
+ OauthClient: list({
237
+ fields: { clientSecret: text() },
238
+ access: { operation: { query: () => true } },
239
+ }),
240
+ },
241
+ })
242
+
243
+ const oauthClient = result.lists.OauthClient
244
+ expect(oauthClient.fields.clientSecret.access?.read).toBeTypeOf('function')
245
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal read-access call fixture
246
+ expect(await oauthClient.fields.clientSecret.access!.read!({} as any)).toBe(false)
247
+ // The app's own access survives (extendList never forwards access).
248
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- access control parameters are runtime values
249
+ expect(oauthClient.access?.operation?.query?.({} as any)).toBe(true)
250
+ })
212
251
  })
213
252
 
214
253
  describe('authPlugin - runtime user-key resolution', () => {
@@ -0,0 +1,270 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { mcp } from '@better-auth/mcp'
3
+ import { deriveAuthLists } from '../src/config/derive-auth-lists.js'
4
+ import type { NormalizedAuthModels } from '../src/config/types.js'
5
+
6
+ /**
7
+ * Coverage for the consolidated plugin-table derivation (issue #992):
8
+ * `deriveAuthLists` now covers better-auth plugin tables (e.g. the MCP
9
+ * plugin's OAuth tables) through the same registry and field-derivation pass
10
+ * as the four base models, instead of a separate converter that dropped
11
+ * references to bare columns. See `auth-lists-drift.test.ts` for a
12
+ * field-by-field comparison against better-auth's own table definitions, and
13
+ * `generated-fk-shape.test.ts` for the generated-schema-text assertions.
14
+ */
15
+
16
+ const defaultModels: NormalizedAuthModels = {
17
+ user: { modelName: 'User', fields: {} },
18
+ session: { modelName: 'Session', fields: {} },
19
+ account: { modelName: 'Account', fields: {} },
20
+ verification: { modelName: 'Verification', fields: {} },
21
+ }
22
+
23
+ describe('deriveAuthLists — better-auth plugin tables (issue #992)', () => {
24
+ it('derives every MCP OAuth table under a PascalCase key, mapped back to its camelCase physical table', () => {
25
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [
26
+ mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
27
+ ])
28
+
29
+ expect(Object.keys(lists).sort()).toEqual([
30
+ 'Account',
31
+ 'OauthAccessToken',
32
+ 'OauthClient',
33
+ 'OauthClientAssertion',
34
+ 'OauthClientResource',
35
+ 'OauthConsent',
36
+ 'OauthRefreshToken',
37
+ 'OauthResource',
38
+ 'Session',
39
+ 'User',
40
+ 'Verification',
41
+ ])
42
+ expect(lists.OauthClient.db?.map).toBe('oauthClient')
43
+ expect(lists.OauthAccessToken.db?.map).toBe('oauthAccessToken')
44
+ expect(lists.OauthConsent.db?.map).toBe('oauthConsent')
45
+ })
46
+
47
+ it('generates a real relationship (not a bare column) for a PK-targeting reference (userId -> user.id)', () => {
48
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [
49
+ mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
50
+ ])
51
+ const userField = lists.OauthClient.fields.user
52
+
53
+ expect(userField.type).toBe('relationship')
54
+ expect(userField.ref).toBe('User.oauthClients')
55
+ expect(userField.db?.foreignKey).toEqual({ map: 'userId' })
56
+ // oauthClient.userId is optional upstream (required: false).
57
+ expect(userField.db?.isNullable).toBe(true)
58
+ })
59
+
60
+ it('carries the referential action from `onDelete: cascade` through to the generated relation', () => {
61
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [
62
+ mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
63
+ ])
64
+ const userField = lists.OauthClient.fields.user
65
+ const extend = userField.db?.extendPrismaSchema
66
+ const relationLine = extend
67
+ ? extend({ relationLine: '@relation(fields: [x], references: [id])' }).relationLine
68
+ : ''
69
+
70
+ expect(relationLine).toContain('onDelete: Cascade')
71
+ })
72
+
73
+ it('maps every declared referential action, not just cascade', () => {
74
+ const plugin = {
75
+ id: 'test-referential-actions',
76
+ schema: {
77
+ widget: {
78
+ modelName: 'widget',
79
+ fields: {
80
+ ownerId: {
81
+ type: 'string',
82
+ required: true,
83
+ references: { model: 'user', field: 'id', onDelete: 'restrict' as const },
84
+ },
85
+ },
86
+ },
87
+ },
88
+ }
89
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
90
+ const extend = lists.Widget.fields.owner.db?.extendPrismaSchema
91
+ const relationLine = extend
92
+ ? extend({ relationLine: '@relation(fields: [x], references: [id])' }).relationLine
93
+ : ''
94
+
95
+ expect(relationLine).toContain('onDelete: Restrict')
96
+ })
97
+
98
+ it('leaves a non-PK-targeting reference as a plain scalar column (clientId -> oauthClient.clientId)', () => {
99
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [
100
+ mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
101
+ ])
102
+ const clientId = lists.OauthAccessToken.fields.clientId
103
+
104
+ expect(clientId.type).toBe('text')
105
+ expect(clientId).not.toHaveProperty('ref')
106
+ // Still indexed, since better-auth declares `index: true` on this column.
107
+ expect(clientId.isIndexed).toBe(true)
108
+ expect(clientId.validation?.isRequired).toBe(true)
109
+ })
110
+
111
+ it('adds a reverse relation on the target base list for every plugin-table reference to it, with no name collisions', () => {
112
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [
113
+ mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
114
+ ])
115
+
116
+ expect(lists.User.fields.oauthClients).toMatchObject({
117
+ type: 'relationship',
118
+ ref: 'OauthClient.user',
119
+ many: true,
120
+ })
121
+ expect(lists.User.fields.oauthAccessTokens).toMatchObject({
122
+ type: 'relationship',
123
+ ref: 'OauthAccessToken.user',
124
+ many: true,
125
+ })
126
+ expect(lists.User.fields.oauthConsents).toMatchObject({
127
+ type: 'relationship',
128
+ ref: 'OauthConsent.user',
129
+ many: true,
130
+ })
131
+ })
132
+
133
+ it('honours fieldName column maps and index: true on plugin scalar fields', () => {
134
+ const plugin = {
135
+ id: 'test-column-map',
136
+ schema: {
137
+ widget: {
138
+ modelName: 'widget',
139
+ fields: {
140
+ label: { type: 'string', fieldName: 'display_label', index: true },
141
+ },
142
+ },
143
+ },
144
+ }
145
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
146
+ const label = lists.Widget.fields.label
147
+
148
+ expect(label.db?.map).toBe('display_label')
149
+ expect(label.isIndexed).toBe(true)
150
+ })
151
+
152
+ it('ships plugin-table lists closed — no access control', () => {
153
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [
154
+ mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
155
+ ])
156
+
157
+ expect(lists.OauthClient.access).toBeUndefined()
158
+ expect(lists.OauthAccessToken.access).toBeUndefined()
159
+ expect(lists.OauthConsent.access).toBeUndefined()
160
+ })
161
+
162
+ it('sets no db.map when a plugin already declares a PascalCase modelName', () => {
163
+ const plugin = {
164
+ id: 'test-passkey',
165
+ schema: { passkey: { modelName: 'Passkey', fields: { publicKey: { type: 'string' } } } },
166
+ }
167
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
168
+
169
+ expect(lists).toHaveProperty('Passkey')
170
+ expect(lists.Passkey.db?.map).toBeUndefined()
171
+ })
172
+
173
+ it('PascalCases a snake_case schema key with no modelName override', () => {
174
+ const plugin = {
175
+ id: 'test-snake-case',
176
+ schema: { oauth_widget: { fields: { data: { type: 'string' } } } },
177
+ }
178
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
179
+
180
+ expect(lists).toHaveProperty('OauthWidget')
181
+ expect(lists.OauthWidget.db?.map).toBe('oauth_widget')
182
+ })
183
+
184
+ it('throws a clear error when a plugin table references a model outside the registry', () => {
185
+ const plugin = {
186
+ id: 'test-bad-reference',
187
+ schema: {
188
+ widget: {
189
+ modelName: 'widget',
190
+ fields: {
191
+ ownerId: {
192
+ type: 'string',
193
+ references: { model: 'nonexistentModel', field: 'id' },
194
+ },
195
+ },
196
+ },
197
+ },
198
+ }
199
+
200
+ expect(() => deriveAuthLists(defaultModels, {}, {}, [plugin])).toThrow(
201
+ /widget\.ownerId.*references unknown model "nonexistentModel"/,
202
+ )
203
+ })
204
+
205
+ it('throws a clear error instead of silently colliding when two id-references on the same table target the same model', () => {
206
+ const plugin = {
207
+ id: 'test-reverse-collision',
208
+ schema: {
209
+ widget: {
210
+ modelName: 'widget',
211
+ fields: {
212
+ ownerId: { type: 'string', references: { model: 'user', field: 'id' } },
213
+ reviewerId: { type: 'string', references: { model: 'user', field: 'id' } },
214
+ },
215
+ },
216
+ },
217
+ }
218
+
219
+ expect(() => deriveAuthLists(defaultModels, {}, {}, [plugin])).toThrow(
220
+ /"widget" has more than one reference to "user".*"widgets"/,
221
+ )
222
+ })
223
+
224
+ it('falls back to a text() column with a warning for a better-auth field type this derivation does not model (e.g. json)', () => {
225
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
226
+ try {
227
+ const plugin = {
228
+ id: 'test-unsupported-type',
229
+ schema: {
230
+ widget: { modelName: 'widget', fields: { metadata: { type: 'json' } } },
231
+ },
232
+ }
233
+
234
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [plugin])
235
+
236
+ expect(lists.Widget.fields.metadata.type).toBe('text')
237
+ expect(warn).toHaveBeenCalledWith(
238
+ expect.stringContaining('Unknown better-auth field type "json"'),
239
+ )
240
+ } finally {
241
+ warn.mockRestore()
242
+ }
243
+ })
244
+
245
+ it('merges a plugin schema extension of a base model directly into that base list, without a separate table', () => {
246
+ const anonymousPlugin = {
247
+ id: 'anonymous',
248
+ schema: { user: { fields: { isAnonymous: { type: 'boolean', required: false } } } },
249
+ }
250
+ const { lists } = deriveAuthLists(defaultModels, {}, {}, [anonymousPlugin])
251
+
252
+ expect(Object.keys(lists).sort()).toEqual(['Account', 'Session', 'User', 'Verification'])
253
+ expect(lists.User.fields).toHaveProperty('isAnonymous')
254
+ })
255
+
256
+ it('resolves a plugin table reference to a base model through a modelName remap (e.g. AuthUser)', () => {
257
+ const remappedModels: NormalizedAuthModels = {
258
+ user: { modelName: 'AuthUser', fields: {} },
259
+ session: { modelName: 'AuthSession', fields: {} },
260
+ account: { modelName: 'AuthAccount', fields: {} },
261
+ verification: { modelName: 'AuthVerification', fields: {} },
262
+ }
263
+ const { lists } = deriveAuthLists(remappedModels, {}, {}, [
264
+ mcp({ loginPage: '/sign-in', consentPage: '/consent', resource: 'https://example.com/mcp' }),
265
+ ])
266
+
267
+ expect(lists.OauthClient.fields.user.ref).toBe('AuthUser.oauthClients')
268
+ expect(lists.AuthUser.fields.oauthClients).toBeDefined()
269
+ })
270
+ })