@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +153 -0
- package/CLAUDE.md +140 -22
- package/dist/client/index.d.ts +0 -16
- package/dist/client/index.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.d.ts +4 -49
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +4 -51
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +42 -37
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +579 -223
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts +0 -3
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +4 -34
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +20 -90
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +89 -67
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -13
- package/dist/index.js.map +1 -1
- package/dist/lists/index.d.ts +9 -5
- package/dist/lists/index.d.ts.map +1 -1
- package/dist/lists/index.js +9 -6
- package/dist/lists/index.js.map +1 -1
- package/dist/mcp/better-auth.d.ts +6 -19
- package/dist/mcp/better-auth.d.ts.map +1 -1
- package/dist/mcp/better-auth.js +6 -20
- package/dist/mcp/better-auth.js.map +1 -1
- package/dist/plugins/index.d.ts +4 -4
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +4 -4
- package/dist/plugins/index.js.map +1 -1
- package/dist/runtime/types.d.ts +2 -9
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js +0 -4
- package/dist/runtime/types.js.map +1 -1
- package/dist/server/index.d.ts +5 -12
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +14 -62
- package/dist/server/index.js.map +1 -1
- package/dist/ui/components/ForgotPasswordForm.d.ts +0 -3
- package/dist/ui/components/ForgotPasswordForm.d.ts.map +1 -1
- package/dist/ui/components/ForgotPasswordForm.js +0 -3
- package/dist/ui/components/ForgotPasswordForm.js.map +1 -1
- package/dist/ui/components/ResetPasswordForm.d.ts +0 -3
- package/dist/ui/components/ResetPasswordForm.d.ts.map +1 -1
- package/dist/ui/components/ResetPasswordForm.js +0 -5
- package/dist/ui/components/ResetPasswordForm.js.map +1 -1
- package/dist/ui/components/SignInForm.d.ts +0 -3
- package/dist/ui/components/SignInForm.d.ts.map +1 -1
- package/dist/ui/components/SignInForm.js +0 -4
- package/dist/ui/components/SignInForm.js.map +1 -1
- package/dist/ui/components/SignUpForm.d.ts +0 -3
- package/dist/ui/components/SignUpForm.d.ts.map +1 -1
- package/dist/ui/components/SignUpForm.js +0 -5
- package/dist/ui/components/SignUpForm.js.map +1 -1
- package/dist/ui/lib/clean-error-message.d.ts +3 -6
- package/dist/ui/lib/clean-error-message.d.ts.map +1 -1
- package/dist/ui/lib/clean-error-message.js +3 -6
- package/dist/ui/lib/clean-error-message.js.map +1 -1
- package/dist/ui/types.d.ts +2 -15
- package/dist/ui/types.d.ts.map +1 -1
- package/dist/ui/types.js +2 -7
- package/dist/ui/types.js.map +1 -1
- package/package.json +12 -5
- package/src/client/index.ts +0 -17
- package/src/config/adopt-better-auth-tables.ts +4 -51
- package/src/config/derive-auth-lists.ts +685 -252
- package/src/config/index.ts +4 -34
- package/src/config/plugin.ts +21 -91
- package/src/config/types.ts +90 -67
- package/src/index.ts +3 -14
- package/src/lists/index.ts +17 -5
- package/src/mcp/better-auth.ts +6 -25
- package/src/plugins/index.ts +4 -5
- package/src/runtime/types.ts +2 -10
- package/src/server/index.ts +17 -70
- package/src/ui/components/ForgotPasswordForm.tsx +0 -3
- package/src/ui/components/ResetPasswordForm.tsx +0 -5
- package/src/ui/components/SignInForm.tsx +0 -4
- package/src/ui/components/SignUpForm.tsx +0 -5
- package/src/ui/lib/clean-error-message.ts +3 -6
- package/src/ui/types.ts +2 -15
- package/tests/auth-lists-drift.test.ts +480 -0
- package/tests/config.test.ts +3 -0
- package/tests/credential-field-read-deny-e2e.test.ts +295 -0
- package/tests/derive-auth-lists.test.ts +263 -6
- package/tests/generated-fk-shape.test.ts +432 -9
- package/tests/mcp-oauth-cascade-e2e.test.ts +267 -0
- package/tests/plugin-derived-keys.test.ts +39 -0
- package/tests/plugin-table-derivation.test.ts +270 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/server/schema-converter.d.ts +0 -81
- package/dist/server/schema-converter.d.ts.map +0 -1
- package/dist/server/schema-converter.js +0 -157
- package/dist/server/schema-converter.js.map +0 -1
- package/src/server/schema-converter.ts +0 -243
- package/tests/schema-converter.test.ts +0 -399
|
@@ -3,8 +3,10 @@ import { config, list } from '@opensaas/stack-core'
|
|
|
3
3
|
import { text, relationship } from '@opensaas/stack-core/fields'
|
|
4
4
|
import type { OpenSaasConfig } from '@opensaas/stack-core'
|
|
5
5
|
import type { Plugin } from '@opensaas/stack-core/extend'
|
|
6
|
+
import { mcp } from '@better-auth/mcp'
|
|
6
7
|
import { generatePrismaSchema } from '@opensaas/stack-cli/generator/prisma'
|
|
7
8
|
import { authPlugin } from '../src/config/plugin.js'
|
|
9
|
+
import { adoptBetterAuthTables } from '../src/config/adopt-better-auth-tables.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Resolve a config through plugin `init` (via `config()`) and each plugin's
|
|
@@ -33,8 +35,8 @@ function modelBlock(schema: string, modelName: string): string {
|
|
|
33
35
|
return schema.slice(start, end === -1 ? undefined : end + 2)
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
describe('generated auth schema — Session/Account
|
|
37
|
-
it('emits onDelete: Cascade and
|
|
38
|
+
describe('generated auth schema — Session/Account/Verification mirror better-auth (issue #679/#753/#937)', () => {
|
|
39
|
+
it('emits onDelete: Cascade, a userId @map, and @@index([userId]) on Session.user and Account.user', async () => {
|
|
38
40
|
const schema = await generateSchema({
|
|
39
41
|
db: { provider: 'sqlite' },
|
|
40
42
|
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
@@ -50,18 +52,33 @@ describe('generated auth schema — Session/Account user FK mirrors better-auth
|
|
|
50
52
|
// column alignment.
|
|
51
53
|
expect(block).toContain('@relation(onDelete: Cascade, fields: [userId], references: [id])')
|
|
52
54
|
|
|
53
|
-
// (b)
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
expect(block).
|
|
55
|
+
// (b) The FK column maps to better-auth's own `userId` column name,
|
|
56
|
+
// not the generator's Keystone-parity default of the field name
|
|
57
|
+
// (`user`) — see issue #935.
|
|
58
|
+
expect(block).toContain('@map("userId")')
|
|
59
|
+
|
|
60
|
+
// (c) The FK index is emitted, matching better-auth's own
|
|
61
|
+
// `session_userId_idx` / `account_userId_idx` — see issue #937 and
|
|
62
|
+
// ADR-0007. It is never emitted against the wrong field name.
|
|
63
|
+
expect(block).toContain('@@index([userId])')
|
|
57
64
|
expect(block).not.toContain('@@index([user])')
|
|
58
65
|
}
|
|
59
66
|
})
|
|
60
67
|
|
|
68
|
+
it('emits @@index([identifier]) on Verification', async () => {
|
|
69
|
+
const schema = await generateSchema({
|
|
70
|
+
db: { provider: 'sqlite' },
|
|
71
|
+
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
72
|
+
lists: {},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const block = modelBlock(schema, 'Verification')
|
|
76
|
+
expect(block).toContain('@@index([identifier])')
|
|
77
|
+
})
|
|
78
|
+
|
|
61
79
|
it('still emits the default @@index for a non-auth relationship FK', async () => {
|
|
62
|
-
// Contrast case: the generic FK-index path is untouched
|
|
63
|
-
//
|
|
64
|
-
// relationship keeps the default Keystone-parity @@index.
|
|
80
|
+
// Contrast case: the generic FK-index path is untouched by the
|
|
81
|
+
// auth-specific FK/index shape above.
|
|
65
82
|
const schema = await generateSchema({
|
|
66
83
|
db: { provider: 'sqlite' },
|
|
67
84
|
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
@@ -80,6 +97,92 @@ describe('generated auth schema — Session/Account user FK mirrors better-auth
|
|
|
80
97
|
})
|
|
81
98
|
})
|
|
82
99
|
|
|
100
|
+
describe('generated auth schema — account.issuer (better-auth 1.7, issue #986)', () => {
|
|
101
|
+
it('emits a required, non-nullable issuer column on Account, positioned after providerId', async () => {
|
|
102
|
+
const schema = await generateSchema({
|
|
103
|
+
db: { provider: 'sqlite' },
|
|
104
|
+
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
105
|
+
lists: {},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const block = modelBlock(schema, 'Account')
|
|
109
|
+
expect(block).toMatch(/issuer\s+String\s/)
|
|
110
|
+
expect(block).not.toMatch(/issuer\s+String\?/)
|
|
111
|
+
|
|
112
|
+
// FIELD_ORDER groups issuer with accountId/providerId — the fields that
|
|
113
|
+
// together form better-auth's table-level @@unique([issuer, accountId]),
|
|
114
|
+
// which this derivation does not yet emit (blocked on #986 reading
|
|
115
|
+
// better-auth's own table-level `indexes` option; #985 only lands the
|
|
116
|
+
// app-supplied `db.indexes` passthrough, exercised below).
|
|
117
|
+
const providerIdLine = block.indexOf('providerId')
|
|
118
|
+
const issuerLine = block.indexOf('issuer')
|
|
119
|
+
expect(providerIdLine).toBeGreaterThan(-1)
|
|
120
|
+
expect(issuerLine).toBeGreaterThan(providerIdLine)
|
|
121
|
+
|
|
122
|
+
// Not yet emitted — see the migration note in the auth package's
|
|
123
|
+
// CHANGELOG and issue #986.
|
|
124
|
+
expect(block).not.toContain('@@unique([issuer, accountId])')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('generated auth schema — adopted index names match better-auth exactly (issue #937)', () => {
|
|
129
|
+
it('derives session_userId_idx / account_userId_idx / verification_identifier_idx under adoptBetterAuthTables()', async () => {
|
|
130
|
+
const schema = await generateSchema({
|
|
131
|
+
db: { provider: 'postgresql' },
|
|
132
|
+
plugins: [
|
|
133
|
+
authPlugin({
|
|
134
|
+
...adoptBetterAuthTables({ useBetterAuthTableNames: true }),
|
|
135
|
+
emailAndPassword: { enabled: true },
|
|
136
|
+
}),
|
|
137
|
+
],
|
|
138
|
+
lists: {},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// No explicit `map:` is set on these auto FK/scalar indexes — Prisma's own
|
|
142
|
+
// implicit naming convention (<mapped table>_<mapped column>_idx) is what
|
|
143
|
+
// has to line up with better-auth's migrator-assigned names, so assert
|
|
144
|
+
// against the raw @@index lines rather than a `map:` argument.
|
|
145
|
+
const session = modelBlock(schema, 'AuthSession')
|
|
146
|
+
expect(session).toContain('@@map("session")')
|
|
147
|
+
expect(session).toContain('@@index([userId])')
|
|
148
|
+
|
|
149
|
+
const account = modelBlock(schema, 'AuthAccount')
|
|
150
|
+
expect(account).toContain('@@map("account")')
|
|
151
|
+
expect(account).toContain('@@index([userId])')
|
|
152
|
+
|
|
153
|
+
const verification = modelBlock(schema, 'AuthVerification')
|
|
154
|
+
expect(verification).toContain('@@map("verification")')
|
|
155
|
+
expect(verification).toContain('@@index([identifier])')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('still indexes all three columns under the default adoptBetterAuthTables() (Auth-prefixed physical tables)', async () => {
|
|
159
|
+
// Without useBetterAuthTableNames, the physical table stays the prefixed
|
|
160
|
+
// model name (@@map("AuthSession"), not better-auth's own lowercase
|
|
161
|
+
// "session") — the index name won't match better-auth's naming
|
|
162
|
+
// convention here, but the columns must still be indexed regardless of
|
|
163
|
+
// table naming.
|
|
164
|
+
const schema = await generateSchema({
|
|
165
|
+
db: { provider: 'postgresql' },
|
|
166
|
+
plugins: [
|
|
167
|
+
authPlugin({
|
|
168
|
+
...adoptBetterAuthTables(),
|
|
169
|
+
emailAndPassword: { enabled: true },
|
|
170
|
+
}),
|
|
171
|
+
],
|
|
172
|
+
lists: {},
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
for (const model of ['AuthSession', 'AuthAccount']) {
|
|
176
|
+
const block = modelBlock(schema, model)
|
|
177
|
+
expect(block).toContain(`@@map("${model}")`)
|
|
178
|
+
expect(block).toContain('@@index([userId])')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const verification = modelBlock(schema, 'AuthVerification')
|
|
182
|
+
expect(verification).toContain('@@index([identifier])')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
83
186
|
describe('generated auth schema — required columns mirror better-auth (issue #863)', () => {
|
|
84
187
|
it('emits a required (non-nullable) userId FK and user relation on Session and Account', async () => {
|
|
85
188
|
const schema = await generateSchema({
|
|
@@ -95,6 +198,10 @@ describe('generated auth schema — required columns mirror better-auth (issue #
|
|
|
95
198
|
expect(block).not.toMatch(/userId\s+String\?/)
|
|
96
199
|
expect(block).toMatch(/user\s+User\s+@relation/)
|
|
97
200
|
expect(block).not.toMatch(/user\s+User\?/)
|
|
201
|
+
|
|
202
|
+
// The physical column must be userId, not the Keystone-parity default
|
|
203
|
+
// of the relationship field name ("user") — issue #935.
|
|
204
|
+
expect(block).not.toContain('@map("user")')
|
|
98
205
|
}
|
|
99
206
|
})
|
|
100
207
|
|
|
@@ -145,6 +252,151 @@ describe('generated auth schema — tableName independent of modelName (issue #8
|
|
|
145
252
|
})
|
|
146
253
|
})
|
|
147
254
|
|
|
255
|
+
describe('generated MCP plugin OAuth schema gets real foreign keys and cascades (issue #992)', () => {
|
|
256
|
+
it('emits onDelete: Cascade, a userId @map, and @@index([userId]) on every OAuth table’s user relation', async () => {
|
|
257
|
+
const schema = await generateSchema({
|
|
258
|
+
db: { provider: 'sqlite' },
|
|
259
|
+
plugins: [
|
|
260
|
+
authPlugin({
|
|
261
|
+
emailAndPassword: { enabled: true },
|
|
262
|
+
betterAuthPlugins: [
|
|
263
|
+
mcp({
|
|
264
|
+
loginPage: '/sign-in',
|
|
265
|
+
consentPage: '/consent',
|
|
266
|
+
resource: 'https://example.com/mcp',
|
|
267
|
+
}),
|
|
268
|
+
],
|
|
269
|
+
}),
|
|
270
|
+
],
|
|
271
|
+
lists: {},
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
for (const model of ['OauthClient', 'OauthAccessToken', 'OauthConsent']) {
|
|
275
|
+
const block = modelBlock(schema, model)
|
|
276
|
+
expect(block).toContain('@relation(onDelete: Cascade, fields: [userId], references: [id])')
|
|
277
|
+
expect(block).toContain('@map("userId")')
|
|
278
|
+
expect(block).toContain('@@index([userId])')
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('maps each OAuth table to its camelCase physical table name via @@map', async () => {
|
|
283
|
+
const schema = await generateSchema({
|
|
284
|
+
db: { provider: 'sqlite' },
|
|
285
|
+
plugins: [
|
|
286
|
+
authPlugin({
|
|
287
|
+
emailAndPassword: { enabled: true },
|
|
288
|
+
betterAuthPlugins: [
|
|
289
|
+
mcp({
|
|
290
|
+
loginPage: '/sign-in',
|
|
291
|
+
consentPage: '/consent',
|
|
292
|
+
resource: 'https://example.com/mcp',
|
|
293
|
+
}),
|
|
294
|
+
],
|
|
295
|
+
}),
|
|
296
|
+
],
|
|
297
|
+
lists: {},
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
expect(modelBlock(schema, 'OauthClient')).toContain('@@map("oauthClient")')
|
|
301
|
+
expect(modelBlock(schema, 'OauthAccessToken')).toContain('@@map("oauthAccessToken")')
|
|
302
|
+
expect(modelBlock(schema, 'OauthConsent')).toContain('@@map("oauthConsent")')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('leaves the non-PK clientId reference (oauthAccessToken/oauthConsent -> oauthClient.clientId) as an indexed plain column, not a relation', async () => {
|
|
306
|
+
const schema = await generateSchema({
|
|
307
|
+
db: { provider: 'sqlite' },
|
|
308
|
+
plugins: [
|
|
309
|
+
authPlugin({
|
|
310
|
+
emailAndPassword: { enabled: true },
|
|
311
|
+
betterAuthPlugins: [
|
|
312
|
+
mcp({
|
|
313
|
+
loginPage: '/sign-in',
|
|
314
|
+
consentPage: '/consent',
|
|
315
|
+
resource: 'https://example.com/mcp',
|
|
316
|
+
}),
|
|
317
|
+
],
|
|
318
|
+
}),
|
|
319
|
+
],
|
|
320
|
+
lists: {},
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
for (const model of ['OauthAccessToken', 'OauthConsent']) {
|
|
324
|
+
const block = modelBlock(schema, model)
|
|
325
|
+
expect(block).toMatch(/clientId\s+String\s/)
|
|
326
|
+
expect(block).not.toContain('clientId OauthClient')
|
|
327
|
+
expect(block).toContain('@@index([clientId])')
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('adds a reverse collection on User for every OAuth table referencing it, and on Session for the two token tables that also reference it, leaving Account/Verification unchanged', async () => {
|
|
332
|
+
const withMcp = await generateSchema({
|
|
333
|
+
db: { provider: 'sqlite' },
|
|
334
|
+
plugins: [
|
|
335
|
+
authPlugin({
|
|
336
|
+
emailAndPassword: { enabled: true },
|
|
337
|
+
betterAuthPlugins: [
|
|
338
|
+
mcp({
|
|
339
|
+
loginPage: '/sign-in',
|
|
340
|
+
consentPage: '/consent',
|
|
341
|
+
resource: 'https://example.com/mcp',
|
|
342
|
+
}),
|
|
343
|
+
],
|
|
344
|
+
}),
|
|
345
|
+
],
|
|
346
|
+
lists: {},
|
|
347
|
+
})
|
|
348
|
+
const withoutMcp = await generateSchema({
|
|
349
|
+
db: { provider: 'sqlite' },
|
|
350
|
+
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
351
|
+
lists: {},
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const user = modelBlock(withMcp, 'User')
|
|
355
|
+
expect(user).toContain('oauthClients')
|
|
356
|
+
expect(user).toContain('oauthAccessTokens')
|
|
357
|
+
expect(user).toContain('oauthRefreshTokens')
|
|
358
|
+
expect(user).toContain('oauthConsents')
|
|
359
|
+
|
|
360
|
+
// oauthAccessToken.sessionId / oauthRefreshToken.sessionId both reference
|
|
361
|
+
// session.id (unlike better-auth's pre-1.7 MCP schema, which had no
|
|
362
|
+
// session-scoped OAuth tables at all) — Session gains reverse collections
|
|
363
|
+
// for both, Account/Verification stay untouched by the MCP plugin.
|
|
364
|
+
const session = modelBlock(withMcp, 'Session')
|
|
365
|
+
expect(session).toContain('oauthAccessTokens')
|
|
366
|
+
expect(session).toContain('oauthRefreshTokens')
|
|
367
|
+
|
|
368
|
+
for (const model of ['Account', 'Verification']) {
|
|
369
|
+
expect(modelBlock(withMcp, model)).toBe(modelBlock(withoutMcp, model))
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('generates a required, non-nullable userId FK on OauthRefreshToken (required upstream), and an optional one on OauthClient (optional upstream)', async () => {
|
|
374
|
+
const schema = await generateSchema({
|
|
375
|
+
db: { provider: 'sqlite' },
|
|
376
|
+
plugins: [
|
|
377
|
+
authPlugin({
|
|
378
|
+
emailAndPassword: { enabled: true },
|
|
379
|
+
betterAuthPlugins: [
|
|
380
|
+
mcp({
|
|
381
|
+
loginPage: '/sign-in',
|
|
382
|
+
consentPage: '/consent',
|
|
383
|
+
resource: 'https://example.com/mcp',
|
|
384
|
+
}),
|
|
385
|
+
],
|
|
386
|
+
}),
|
|
387
|
+
],
|
|
388
|
+
lists: {},
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
const refreshToken = modelBlock(schema, 'OauthRefreshToken')
|
|
392
|
+
expect(refreshToken).toMatch(/userId\s+String\s/)
|
|
393
|
+
expect(refreshToken).not.toMatch(/userId\s+String\?/)
|
|
394
|
+
|
|
395
|
+
const client = modelBlock(schema, 'OauthClient')
|
|
396
|
+
expect(client).toMatch(/userId\s+String\?/)
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
|
|
148
400
|
describe('generated RateLimit schema mirrors better-auth exactly (issue #909)', () => {
|
|
149
401
|
it('does not add a RateLimit model when storage is unset', async () => {
|
|
150
402
|
const schema = await generateSchema({
|
|
@@ -225,3 +477,174 @@ describe('generated RateLimit schema mirrors better-auth exactly (issue #909)',
|
|
|
225
477
|
expect(schema).toContain('model RateLimit')
|
|
226
478
|
})
|
|
227
479
|
})
|
|
480
|
+
|
|
481
|
+
describe('app-supplied db.indexes on derived auth lists (issue #985)', () => {
|
|
482
|
+
it('emits a composite index on Verification and suppresses the derived single-column index on identifier', async () => {
|
|
483
|
+
const schema = await generateSchema({
|
|
484
|
+
db: { provider: 'sqlite' },
|
|
485
|
+
plugins: [
|
|
486
|
+
authPlugin({
|
|
487
|
+
emailAndPassword: { enabled: true },
|
|
488
|
+
verification: {
|
|
489
|
+
indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }],
|
|
490
|
+
},
|
|
491
|
+
}),
|
|
492
|
+
],
|
|
493
|
+
lists: {},
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
const block = modelBlock(schema, 'Verification')
|
|
497
|
+
expect(block).toContain('@@index([identifier, createdAt(sort: Desc)])')
|
|
498
|
+
// The derived single-column index on identifier is suppressed — only the
|
|
499
|
+
// composite survives (ADR-0035).
|
|
500
|
+
expect(block).not.toContain('@@index([identifier])')
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('adopts a live named unique constraint on User.email, suppressing the derived inline @unique', async () => {
|
|
504
|
+
const schema = await generateSchema({
|
|
505
|
+
db: { provider: 'sqlite' },
|
|
506
|
+
plugins: [
|
|
507
|
+
authPlugin({
|
|
508
|
+
emailAndPassword: { enabled: true },
|
|
509
|
+
user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] },
|
|
510
|
+
}),
|
|
511
|
+
],
|
|
512
|
+
lists: {},
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
const block = modelBlock(schema, 'User')
|
|
516
|
+
expect(block).toContain('@@unique([email], map: "user_email_key")')
|
|
517
|
+
expect(block).not.toMatch(/email\s+String\s+@unique/)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('suppresses per-column only — every other derived index on the model still emits', async () => {
|
|
521
|
+
const schema = await generateSchema({
|
|
522
|
+
db: { provider: 'sqlite' },
|
|
523
|
+
plugins: [
|
|
524
|
+
authPlugin({
|
|
525
|
+
emailAndPassword: { enabled: true },
|
|
526
|
+
user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] },
|
|
527
|
+
}),
|
|
528
|
+
],
|
|
529
|
+
lists: {},
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
// Session/Account's derived FK index on the User relation is untouched —
|
|
533
|
+
// suppression only ever applies to the column(s) an app entry names.
|
|
534
|
+
for (const model of ['Session', 'Account']) {
|
|
535
|
+
expect(modelBlock(schema, model)).toContain('@@index([userId])')
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('suppresses the derived FK index on a relationship field, not just scalars', async () => {
|
|
540
|
+
// A relationship field's own generator defaults its FK index to indexed
|
|
541
|
+
// whenever isIndexed is *omitted* (unlike a scalar field) — suppression
|
|
542
|
+
// must set isIndexed: false explicitly, or the derived @@index([userId])
|
|
543
|
+
// survives and collides with the app's own named entry.
|
|
544
|
+
const schema = await generateSchema({
|
|
545
|
+
db: { provider: 'sqlite' },
|
|
546
|
+
plugins: [
|
|
547
|
+
authPlugin({
|
|
548
|
+
emailAndPassword: { enabled: true },
|
|
549
|
+
session: { indexes: [{ fields: ['user'], name: 'session_user_idx' }] },
|
|
550
|
+
}),
|
|
551
|
+
],
|
|
552
|
+
lists: {},
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
const block = modelBlock(schema, 'Session')
|
|
556
|
+
expect(block).toContain('@@index([userId], map: "session_user_idx")')
|
|
557
|
+
// The derived, unnamed @@index([userId]) must not also survive alongside it.
|
|
558
|
+
expect(block).not.toContain('@@index([userId])')
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it("resolves the entry's field key through the model's own column map (@map)", async () => {
|
|
562
|
+
const schema = await generateSchema({
|
|
563
|
+
db: { provider: 'sqlite' },
|
|
564
|
+
plugins: [
|
|
565
|
+
authPlugin({
|
|
566
|
+
emailAndPassword: { enabled: true },
|
|
567
|
+
verification: {
|
|
568
|
+
fields: { identifier: 'ident_col' },
|
|
569
|
+
indexes: [{ fields: ['identifier'] }],
|
|
570
|
+
},
|
|
571
|
+
}),
|
|
572
|
+
],
|
|
573
|
+
lists: {},
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
const block = modelBlock(schema, 'Verification')
|
|
577
|
+
// db.indexes names the OpenSaaS field key, not the mapped column name —
|
|
578
|
+
// Prisma's @@index references the field, independent of its own @map.
|
|
579
|
+
expect(block).toContain('@map("ident_col")')
|
|
580
|
+
expect(block).toContain('@@index([identifier])')
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('#921: user.email and session.token both round-trip under adopted constraint names', async () => {
|
|
584
|
+
const schema = await generateSchema({
|
|
585
|
+
db: { provider: 'sqlite' },
|
|
586
|
+
plugins: [
|
|
587
|
+
authPlugin({
|
|
588
|
+
emailAndPassword: { enabled: true },
|
|
589
|
+
user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] },
|
|
590
|
+
session: { indexes: [{ fields: ['token'], unique: true, name: 'session_token_key' }] },
|
|
591
|
+
}),
|
|
592
|
+
],
|
|
593
|
+
lists: {},
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
const user = modelBlock(schema, 'User')
|
|
597
|
+
expect(user).toContain('@@unique([email], map: "user_email_key")')
|
|
598
|
+
expect(user).not.toMatch(/email\s+String\s+@unique/)
|
|
599
|
+
|
|
600
|
+
const session = modelBlock(schema, 'Session')
|
|
601
|
+
expect(session).toContain('@@unique([token], map: "session_token_key")')
|
|
602
|
+
expect(session).not.toMatch(/token\s+String\s+@unique/)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('fails generation naming the model, the entry, and the bad field for an unknown field', async () => {
|
|
606
|
+
await expect(
|
|
607
|
+
generateSchema({
|
|
608
|
+
db: { provider: 'sqlite' },
|
|
609
|
+
plugins: [
|
|
610
|
+
authPlugin({
|
|
611
|
+
emailAndPassword: { enabled: true },
|
|
612
|
+
verification: { indexes: [{ fields: ['doesNotExist'] }] },
|
|
613
|
+
}),
|
|
614
|
+
],
|
|
615
|
+
lists: {},
|
|
616
|
+
}),
|
|
617
|
+
).rejects.toThrow(/Verification.*references unknown field "doesNotExist"/)
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('names the remapped list key when modelName overrides the derived key', async () => {
|
|
621
|
+
await expect(
|
|
622
|
+
generateSchema({
|
|
623
|
+
db: { provider: 'sqlite' },
|
|
624
|
+
plugins: [
|
|
625
|
+
authPlugin({
|
|
626
|
+
emailAndPassword: { enabled: true },
|
|
627
|
+
verification: {
|
|
628
|
+
modelName: 'AuthVerification',
|
|
629
|
+
indexes: [{ fields: ['doesNotExist'] }],
|
|
630
|
+
},
|
|
631
|
+
}),
|
|
632
|
+
],
|
|
633
|
+
lists: {},
|
|
634
|
+
}),
|
|
635
|
+
).rejects.toThrow(/AuthVerification.*references unknown field "doesNotExist"/)
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('leaves existing auth-list derivation unchanged when no indexes are configured', async () => {
|
|
639
|
+
const withIndexes = await generateSchema({
|
|
640
|
+
db: { provider: 'sqlite' },
|
|
641
|
+
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
642
|
+
lists: {},
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
for (const model of ['User', 'Session', 'Account', 'Verification']) {
|
|
646
|
+
expect(modelBlock(withIndexes, model)).not.toContain('@@unique([')
|
|
647
|
+
expect(modelBlock(withIndexes, model)).not.toContain('@@index([identifier, ')
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
})
|