@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +60 -0
- package/CLAUDE.md +50 -3
- package/dist/config/adopt-better-auth-tables.d.ts +23 -3
- package/dist/config/adopt-better-auth-tables.d.ts.map +1 -1
- package/dist/config/adopt-better-auth-tables.js +7 -2
- package/dist/config/adopt-better-auth-tables.js.map +1 -1
- package/dist/config/derive-auth-lists.d.ts +6 -1
- package/dist/config/derive-auth-lists.d.ts.map +1 -1
- package/dist/config/derive-auth-lists.js +63 -16
- package/dist/config/derive-auth-lists.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +12 -5
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +7 -2
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +62 -7
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server/build-better-auth-options.test.d.ts +2 -0
- package/dist/server/build-better-auth-options.test.d.ts.map +1 -0
- package/dist/server/build-better-auth-options.test.js +29 -0
- package/dist/server/build-better-auth-options.test.js.map +1 -0
- package/dist/server/get-session-from-auth.test.d.ts +2 -0
- package/dist/server/get-session-from-auth.test.d.ts.map +1 -0
- package/dist/server/get-session-from-auth.test.js +25 -0
- package/dist/server/get-session-from-auth.test.js.map +1 -0
- package/dist/server/index.d.ts +108 -15
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +151 -63
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema-converter.d.ts +15 -6
- package/dist/server/schema-converter.d.ts.map +1 -1
- package/dist/server/schema-converter.js +14 -2
- package/dist/server/schema-converter.js.map +1 -1
- package/package.json +5 -5
- package/src/config/adopt-better-auth-tables.ts +31 -4
- package/src/config/derive-auth-lists.ts +82 -21
- package/src/config/index.ts +18 -5
- package/src/config/plugin.ts +7 -2
- package/src/config/types.ts +63 -9
- package/src/server/build-better-auth-options.test.ts +59 -0
- package/src/server/get-session-from-auth.test.ts +52 -0
- package/src/server/index.ts +273 -41
- package/src/server/schema-converter.ts +29 -8
- package/tests/adopt-better-auth-tables.test.ts +73 -0
- package/tests/config.test.ts +161 -0
- package/tests/derive-auth-lists.test.ts +104 -0
- package/tests/generated-fk-shape.test.ts +81 -0
- package/tests/plugin-schema-placement.test.ts +39 -0
- package/tests/rate-limit-e2e.test.ts +239 -0
- package/tests/schema-converter.test.ts +58 -0
- package/tests/server.test.ts +310 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +7 -1
package/tests/config.test.ts
CHANGED
|
@@ -223,6 +223,82 @@ describe('normalizeAuthConfig', () => {
|
|
|
223
223
|
expect(result.models.session.tableName).toBe('sessions')
|
|
224
224
|
})
|
|
225
225
|
})
|
|
226
|
+
|
|
227
|
+
describe('models.rateLimit (issue #909)', () => {
|
|
228
|
+
it('is absent when rateLimit is not configured', () => {
|
|
229
|
+
const result = normalizeAuthConfig({})
|
|
230
|
+
|
|
231
|
+
expect(result.models.rateLimit).toBeUndefined()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('is absent when storage is "memory" or unset', () => {
|
|
235
|
+
expect(normalizeAuthConfig({ rateLimit: { enabled: true } }).models.rateLimit).toBeUndefined()
|
|
236
|
+
expect(
|
|
237
|
+
normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'memory' } }).models.rateLimit,
|
|
238
|
+
).toBeUndefined()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('is absent when storage is "secondary-storage"', () => {
|
|
242
|
+
expect(
|
|
243
|
+
normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'secondary-storage' } }).models
|
|
244
|
+
.rateLimit,
|
|
245
|
+
).toBeUndefined()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('is present with the default RateLimit model name when storage is "database"', () => {
|
|
249
|
+
const result = normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'database' } })
|
|
250
|
+
|
|
251
|
+
expect(result.models.rateLimit).toEqual({
|
|
252
|
+
modelName: 'RateLimit',
|
|
253
|
+
tableName: undefined,
|
|
254
|
+
fields: {},
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('is present even when enabled is false, since better-auth still expects the table', () => {
|
|
259
|
+
const result = normalizeAuthConfig({ rateLimit: { enabled: false, storage: 'database' } })
|
|
260
|
+
|
|
261
|
+
expect(result.models.rateLimit).toBeDefined()
|
|
262
|
+
expect(result.models.rateLimit?.modelName).toBe('RateLimit')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('honours a custom modelName/tableName/fields/schema on the rateLimit model', () => {
|
|
266
|
+
const result = normalizeAuthConfig({
|
|
267
|
+
rateLimit: {
|
|
268
|
+
enabled: true,
|
|
269
|
+
storage: 'database',
|
|
270
|
+
modelName: 'AuthRateLimit',
|
|
271
|
+
tableName: 'rate_limit',
|
|
272
|
+
fields: { key: 'limit_key' },
|
|
273
|
+
schema: 'auth',
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
expect(result.models.rateLimit).toEqual({
|
|
278
|
+
modelName: 'AuthRateLimit',
|
|
279
|
+
tableName: 'rate_limit',
|
|
280
|
+
fields: { key: 'limit_key' },
|
|
281
|
+
schema: 'auth',
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('resolves the plugin-level schema default for the rateLimit model like the other four', () => {
|
|
286
|
+
const result = normalizeAuthConfig({
|
|
287
|
+
schema: 'auth',
|
|
288
|
+
rateLimit: { enabled: true, storage: 'database' },
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
expect(result.models.rateLimit?.schema).toBe('auth')
|
|
292
|
+
expect(result.models.user.schema).toBe('auth')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('keeps storage on the normalized top-level rateLimit config', () => {
|
|
296
|
+
const result = normalizeAuthConfig({ rateLimit: { enabled: true, storage: 'database' } })
|
|
297
|
+
|
|
298
|
+
expect(result.rateLimit?.storage).toBe('database')
|
|
299
|
+
expect(result.rateLimit?.enabled).toBe(true)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
226
302
|
})
|
|
227
303
|
|
|
228
304
|
describe('authPlugin', () => {
|
|
@@ -585,4 +661,89 @@ describe('authPlugin', () => {
|
|
|
585
661
|
expect(result.lists.User.access).toBe(extendAccess)
|
|
586
662
|
})
|
|
587
663
|
})
|
|
664
|
+
|
|
665
|
+
describe('RateLimit list (issue #909)', () => {
|
|
666
|
+
it('does not inject a RateLimit list when rateLimit is unconfigured', async () => {
|
|
667
|
+
const result = await config({
|
|
668
|
+
plugins: [authPlugin({})],
|
|
669
|
+
lists: {},
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
expect(result.lists).not.toHaveProperty('RateLimit')
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it('does not inject a RateLimit list for storage "memory" or "secondary-storage"', async () => {
|
|
676
|
+
const memory = await config({
|
|
677
|
+
plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'memory' } })],
|
|
678
|
+
lists: {},
|
|
679
|
+
})
|
|
680
|
+
expect(memory.lists).not.toHaveProperty('RateLimit')
|
|
681
|
+
|
|
682
|
+
const secondary = await config({
|
|
683
|
+
plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'secondary-storage' } })],
|
|
684
|
+
lists: {},
|
|
685
|
+
})
|
|
686
|
+
expect(secondary.lists).not.toHaveProperty('RateLimit')
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('injects a RateLimit list when storage is "database"', async () => {
|
|
690
|
+
const result = await config({
|
|
691
|
+
plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'database' } })],
|
|
692
|
+
lists: {},
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
const rateLimit = result.lists.RateLimit
|
|
696
|
+
expect(rateLimit).toBeDefined()
|
|
697
|
+
expect(rateLimit.fields).toHaveProperty('key')
|
|
698
|
+
expect(rateLimit.fields).toHaveProperty('count')
|
|
699
|
+
expect(rateLimit.fields).toHaveProperty('lastRequest')
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('injects a RateLimit list even when enabled is false, since better-auth still expects the table', async () => {
|
|
703
|
+
const result = await config({
|
|
704
|
+
plugins: [authPlugin({ rateLimit: { enabled: false, storage: 'database' } })],
|
|
705
|
+
lists: {},
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
expect(result.lists.RateLimit).toBeDefined()
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('ships the RateLimit list closed by default (ADR-0013)', async () => {
|
|
712
|
+
const result = await config({
|
|
713
|
+
plugins: [authPlugin({ rateLimit: { enabled: true, storage: 'database' } })],
|
|
714
|
+
lists: {},
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
expect(result.lists.RateLimit.access).toBeUndefined()
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('applies access.rateLimit to the derived list', async () => {
|
|
721
|
+
const rateLimitQuery = () => true
|
|
722
|
+
const result = await config({
|
|
723
|
+
plugins: [
|
|
724
|
+
authPlugin({
|
|
725
|
+
rateLimit: { enabled: true, storage: 'database' },
|
|
726
|
+
access: { rateLimit: { operation: { query: rateLimitQuery } } },
|
|
727
|
+
}),
|
|
728
|
+
],
|
|
729
|
+
lists: {},
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
expect(result.lists.RateLimit.access?.operation?.query).toBe(rateLimitQuery)
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
it('respects a custom modelName on the rateLimit config', async () => {
|
|
736
|
+
const result = await config({
|
|
737
|
+
plugins: [
|
|
738
|
+
authPlugin({
|
|
739
|
+
rateLimit: { enabled: true, storage: 'database', modelName: 'AuthRateLimit' },
|
|
740
|
+
}),
|
|
741
|
+
],
|
|
742
|
+
lists: {},
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
expect(result.lists).toHaveProperty('AuthRateLimit')
|
|
746
|
+
expect(result.lists).not.toHaveProperty('RateLimit')
|
|
747
|
+
})
|
|
748
|
+
})
|
|
588
749
|
})
|
|
@@ -344,6 +344,110 @@ describe('deriveAuthLists - schema placement', () => {
|
|
|
344
344
|
})
|
|
345
345
|
})
|
|
346
346
|
|
|
347
|
+
describe('deriveAuthLists - RateLimit list (rateLimit.storage === "database")', () => {
|
|
348
|
+
it('is absent when no rateLimit model is supplied', () => {
|
|
349
|
+
const { keys, lists } = deriveAuthLists(defaultModels)
|
|
350
|
+
|
|
351
|
+
expect(keys.rateLimit).toBeUndefined()
|
|
352
|
+
expect(lists.RateLimit).toBeUndefined()
|
|
353
|
+
expect(Object.keys(lists).sort()).toEqual(['Account', 'Session', 'User', 'Verification'])
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('derives a RateLimit list keyed by the default model name when present', () => {
|
|
357
|
+
const models: NormalizedAuthModels = {
|
|
358
|
+
...defaultModels,
|
|
359
|
+
rateLimit: { modelName: 'RateLimit', fields: {} },
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const { keys, lists } = deriveAuthLists(models)
|
|
363
|
+
|
|
364
|
+
expect(keys.rateLimit).toBe('RateLimit')
|
|
365
|
+
expect(Object.keys(lists).sort()).toEqual([
|
|
366
|
+
'Account',
|
|
367
|
+
'RateLimit',
|
|
368
|
+
'Session',
|
|
369
|
+
'User',
|
|
370
|
+
'Verification',
|
|
371
|
+
])
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('mirrors better-auth’s rateLimit table shape: key/count/lastRequest, all required, no defaults', () => {
|
|
375
|
+
const models: NormalizedAuthModels = {
|
|
376
|
+
...defaultModels,
|
|
377
|
+
rateLimit: { modelName: 'RateLimit', fields: {} },
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const { lists } = deriveAuthLists(models)
|
|
381
|
+
const rateLimit = lists.RateLimit
|
|
382
|
+
|
|
383
|
+
expect(rateLimit.fields.key.type).toBe('text')
|
|
384
|
+
expect(rateLimit.fields.key.isIndexed).toBe('unique')
|
|
385
|
+
expect(rateLimit.fields.key.validation?.isRequired).toBe(true)
|
|
386
|
+
expect(rateLimit.fields.key.defaultValue).toBeUndefined()
|
|
387
|
+
|
|
388
|
+
expect(rateLimit.fields.count.type).toBe('integer')
|
|
389
|
+
expect(rateLimit.fields.count.validation?.isRequired).toBe(true)
|
|
390
|
+
expect(rateLimit.fields.count.db?.isNullable).toBe(false)
|
|
391
|
+
expect(rateLimit.fields.count.defaultValue).toBeUndefined()
|
|
392
|
+
|
|
393
|
+
expect(rateLimit.fields.lastRequest.type).toBe('bigInt')
|
|
394
|
+
expect(rateLimit.fields.lastRequest.validation?.isRequired).toBe(true)
|
|
395
|
+
expect(rateLimit.fields.lastRequest.db?.isNullable).toBe(false)
|
|
396
|
+
expect(rateLimit.fields.lastRequest.defaultValue).toBeUndefined()
|
|
397
|
+
|
|
398
|
+
// Exactly these three fields — no createdAt/updatedAt columns on this list.
|
|
399
|
+
expect(Object.keys(rateLimit.fields).sort()).toEqual(['count', 'key', 'lastRequest'])
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('does not opt into auto-timestamps, unlike the other four Auth lists', () => {
|
|
403
|
+
const models: NormalizedAuthModels = {
|
|
404
|
+
...defaultModels,
|
|
405
|
+
rateLimit: { modelName: 'RateLimit', fields: {} },
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const { lists } = deriveAuthLists(models)
|
|
409
|
+
|
|
410
|
+
expect(lists.RateLimit.db?.timestamps).toBeUndefined()
|
|
411
|
+
expect(lists.User.db?.timestamps).toBe(true)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('applies a custom modelName, tableName, field column maps, and schema like the other models', () => {
|
|
415
|
+
const models: NormalizedAuthModels = {
|
|
416
|
+
...defaultModels,
|
|
417
|
+
rateLimit: {
|
|
418
|
+
modelName: 'AuthRateLimit',
|
|
419
|
+
tableName: 'rate_limit',
|
|
420
|
+
fields: { key: 'limit_key', count: 'hit_count', lastRequest: 'last_hit_at' },
|
|
421
|
+
schema: 'auth',
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const { keys, lists } = deriveAuthLists(models)
|
|
426
|
+
|
|
427
|
+
expect(keys.rateLimit).toBe('AuthRateLimit')
|
|
428
|
+
const rateLimit = lists.AuthRateLimit
|
|
429
|
+
expect(rateLimit.db?.map).toBe('rate_limit')
|
|
430
|
+
expect(rateLimit.db?.schema).toBe('auth')
|
|
431
|
+
expect(rateLimit.fields.key.db?.map).toBe('limit_key')
|
|
432
|
+
expect(rateLimit.fields.count.db?.map).toBe('hit_count')
|
|
433
|
+
expect(rateLimit.fields.lastRequest.db?.map).toBe('last_hit_at')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('ships closed (no access) unless accessConfig.rateLimit is supplied', () => {
|
|
437
|
+
const models: NormalizedAuthModels = {
|
|
438
|
+
...defaultModels,
|
|
439
|
+
rateLimit: { modelName: 'RateLimit', fields: {} },
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const { lists: closed } = deriveAuthLists(models)
|
|
443
|
+
expect(closed.RateLimit.access).toBeUndefined()
|
|
444
|
+
|
|
445
|
+
const rateLimitAccess = { operation: { query: () => true } }
|
|
446
|
+
const { lists: open } = deriveAuthLists(models, {}, { rateLimit: rateLimitAccess })
|
|
447
|
+
expect(open.RateLimit.access).toBe(rateLimitAccess)
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
|
|
347
451
|
describe('deriveAuthLists - extendUserList', () => {
|
|
348
452
|
it('adds custom fields to the derived user list', () => {
|
|
349
453
|
const { lists } = deriveAuthLists(
|
|
@@ -144,3 +144,84 @@ describe('generated auth schema — tableName independent of modelName (issue #8
|
|
|
144
144
|
}
|
|
145
145
|
})
|
|
146
146
|
})
|
|
147
|
+
|
|
148
|
+
describe('generated RateLimit schema mirrors better-auth exactly (issue #909)', () => {
|
|
149
|
+
it('does not add a RateLimit model when storage is unset', async () => {
|
|
150
|
+
const schema = await generateSchema({
|
|
151
|
+
db: { provider: 'sqlite' },
|
|
152
|
+
plugins: [authPlugin({ emailAndPassword: { enabled: true } })],
|
|
153
|
+
lists: {},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
expect(schema).not.toContain('model RateLimit')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('emits key (unique, non-null), count (non-null Int), lastRequest (non-null BigInt), no createdAt/updatedAt, no @default', async () => {
|
|
160
|
+
const schema = await generateSchema({
|
|
161
|
+
db: { provider: 'sqlite' },
|
|
162
|
+
plugins: [
|
|
163
|
+
authPlugin({
|
|
164
|
+
emailAndPassword: { enabled: true },
|
|
165
|
+
rateLimit: { enabled: true, storage: 'database' },
|
|
166
|
+
}),
|
|
167
|
+
],
|
|
168
|
+
lists: {},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const block = modelBlock(schema, 'RateLimit')
|
|
172
|
+
|
|
173
|
+
expect(block).toMatch(/key\s+String\s+@unique/)
|
|
174
|
+
expect(block).toMatch(/count\s+Int\s/)
|
|
175
|
+
expect(block).not.toMatch(/count\s+Int\?/)
|
|
176
|
+
expect(block).toMatch(/lastRequest\s+BigInt\s/)
|
|
177
|
+
expect(block).not.toMatch(/lastRequest\s+BigInt\?/)
|
|
178
|
+
|
|
179
|
+
expect(block).not.toContain('createdAt')
|
|
180
|
+
expect(block).not.toContain('updatedAt')
|
|
181
|
+
// The system `id` field carries its own @default(cuid()) — only the
|
|
182
|
+
// three better-auth-mirrored columns must carry none.
|
|
183
|
+
expect(block).not.toMatch(/key\s+String\s+@unique\s+@default/)
|
|
184
|
+
expect(block).not.toMatch(/count\s+Int\s+@default/)
|
|
185
|
+
expect(block).not.toMatch(/lastRequest\s+BigInt\s+@default/)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('honours a custom modelName/tableName/fields/schema on the rateLimit model', async () => {
|
|
189
|
+
const schema = await generateSchema({
|
|
190
|
+
db: { provider: 'postgresql' },
|
|
191
|
+
plugins: [
|
|
192
|
+
authPlugin({
|
|
193
|
+
emailAndPassword: { enabled: true },
|
|
194
|
+
rateLimit: {
|
|
195
|
+
enabled: true,
|
|
196
|
+
storage: 'database',
|
|
197
|
+
modelName: 'AuthRateLimit',
|
|
198
|
+
tableName: 'rate_limit',
|
|
199
|
+
fields: { key: 'limit_key', count: 'hit_count', lastRequest: 'last_hit_at' },
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
],
|
|
203
|
+
lists: {},
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const block = modelBlock(schema, 'AuthRateLimit')
|
|
207
|
+
expect(block).toContain('@@map("rate_limit")')
|
|
208
|
+
expect(block).toContain('@map("limit_key")')
|
|
209
|
+
expect(block).toContain('@map("hit_count")')
|
|
210
|
+
expect(block).toContain('@map("last_hit_at")')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('produces a RateLimit model even when enabled is false, since better-auth still expects the table', async () => {
|
|
214
|
+
const schema = await generateSchema({
|
|
215
|
+
db: { provider: 'sqlite' },
|
|
216
|
+
plugins: [
|
|
217
|
+
authPlugin({
|
|
218
|
+
emailAndPassword: { enabled: true },
|
|
219
|
+
rateLimit: { enabled: false, storage: 'database' },
|
|
220
|
+
}),
|
|
221
|
+
],
|
|
222
|
+
lists: {},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
expect(schema).toContain('model RateLimit')
|
|
226
|
+
})
|
|
227
|
+
})
|
|
@@ -119,3 +119,42 @@ describe('authPlugin - schema placement (adopt existing auth-schema install)', (
|
|
|
119
119
|
expect(result.db.schemas).toContain('auth_internal')
|
|
120
120
|
})
|
|
121
121
|
})
|
|
122
|
+
|
|
123
|
+
describe('authPlugin - RateLimit list schema placement', () => {
|
|
124
|
+
it('places the RateLimit list in the configured schema and wires the datasource', async () => {
|
|
125
|
+
const result = await generationConfig({
|
|
126
|
+
db: { provider: 'postgresql' },
|
|
127
|
+
plugins: [
|
|
128
|
+
authPlugin({
|
|
129
|
+
schema: 'auth',
|
|
130
|
+
rateLimit: { enabled: true, storage: 'database', modelName: 'AuthRateLimit' },
|
|
131
|
+
}),
|
|
132
|
+
],
|
|
133
|
+
lists: {},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(result.lists.AuthRateLimit.db).toEqual({ map: 'AuthRateLimit', schema: 'auth' })
|
|
137
|
+
expect(result.db.schemas).toContain('auth')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('honours a per-model schema override independent of the plugin-level schema', async () => {
|
|
141
|
+
const result = await generationConfig({
|
|
142
|
+
db: { provider: 'postgresql' },
|
|
143
|
+
plugins: [
|
|
144
|
+
authPlugin({
|
|
145
|
+
schema: 'auth',
|
|
146
|
+
rateLimit: {
|
|
147
|
+
enabled: true,
|
|
148
|
+
storage: 'database',
|
|
149
|
+
modelName: 'AuthRateLimit',
|
|
150
|
+
schema: 'auth_internal',
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
],
|
|
154
|
+
lists: {},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(result.lists.AuthRateLimit.db?.schema).toBe('auth_internal')
|
|
158
|
+
expect(result.db.schemas).toContain('auth_internal')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect, afterAll } 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 the database-backed rate limiter (issue #909)
|
|
14
|
+
* actually works: it generates a real Prisma schema (with the derived
|
|
15
|
+
* `RateLimit` list), pushes it to a real SQLite database, constructs real
|
|
16
|
+
* `betterAuth()` instances against that database via `createAuth()` (the
|
|
17
|
+
* package's own source, not a stale build), and drives them through real
|
|
18
|
+
* HTTP-shaped requests via `auth.handler()`.
|
|
19
|
+
*
|
|
20
|
+
* This is the one guard in the suite that touches a live database, so it
|
|
21
|
+
* follows the same pattern as
|
|
22
|
+
* `packages/create-opensaas-app/tests/scaffold-first-run-guard.test.ts`
|
|
23
|
+
* (see ADR-0002): opt-in via an env flag, kept out of the fast unit lane, run
|
|
24
|
+
* only in the `e2e` CI job where `pnpm install && pnpm build` have already
|
|
25
|
+
* run. It borrows the `opensaas`/`prisma` binaries and the
|
|
26
|
+
* `@prisma/adapter-better-sqlite3` dependency from `examples/starter-auth`'s
|
|
27
|
+
* already-installed `node_modules` (symlinked into an OS temp dir) instead of
|
|
28
|
+
* adding a live-database toolchain to this package's own dependencies.
|
|
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
|
+
/** The `opensaas` CLI the temp project's `generate` step invokes. */
|
|
36
|
+
const opensaasCli = path.join(repoRoot, 'packages/cli/dist/index.js')
|
|
37
|
+
/** `createAuth()` (imported from source above) resolves `@opensaas/stack-core` through this build. */
|
|
38
|
+
const coreDist = path.join(repoRoot, 'packages/core/dist')
|
|
39
|
+
|
|
40
|
+
/** The toolchain (opensaas/prisma binaries + the sqlite adapter) borrowed from a working example. */
|
|
41
|
+
const toolchainNodeModules = path.join(repoRoot, 'examples/starter-auth/node_modules')
|
|
42
|
+
|
|
43
|
+
const guardEnabled = process.env.RUN_RATE_LIMIT_E2E === '1'
|
|
44
|
+
|
|
45
|
+
const prerequisitesPresent =
|
|
46
|
+
guardEnabled &&
|
|
47
|
+
existsSync(opensaasCli) &&
|
|
48
|
+
existsSync(coreDist) &&
|
|
49
|
+
existsSync(path.join(toolchainNodeModules, '.bin', 'opensaas')) &&
|
|
50
|
+
existsSync(path.join(toolchainNodeModules, '.bin', 'prisma')) &&
|
|
51
|
+
existsSync(path.join(toolchainNodeModules, '@prisma', 'adapter-better-sqlite3'))
|
|
52
|
+
|
|
53
|
+
/** Build the temp project's `opensaas.config.ts`: sqlite + a database-backed rate limiter. */
|
|
54
|
+
function makeConfigSource(window: number, max: number): string {
|
|
55
|
+
return `import { config } from '@opensaas/stack-core'
|
|
56
|
+
import { authPlugin } from '@opensaas/stack-auth'
|
|
57
|
+
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
|
58
|
+
|
|
59
|
+
export default config({
|
|
60
|
+
plugins: [
|
|
61
|
+
authPlugin({
|
|
62
|
+
emailAndPassword: { enabled: true },
|
|
63
|
+
rateLimit: { enabled: true, window: ${window}, max: ${max}, storage: 'database' },
|
|
64
|
+
}),
|
|
65
|
+
],
|
|
66
|
+
db: {
|
|
67
|
+
provider: 'sqlite',
|
|
68
|
+
prismaClientConstructor: (PrismaClient) => {
|
|
69
|
+
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || './dev.db' })
|
|
70
|
+
return new PrismaClient({ adapter })
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
lists: {},
|
|
74
|
+
})
|
|
75
|
+
`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A GET request against a real (rate-limited) better-auth endpoint from a fixed "client". */
|
|
79
|
+
function sessionRequest(ip: string): Request {
|
|
80
|
+
return new Request('http://localhost:3000/api/auth/get-session', {
|
|
81
|
+
method: 'GET',
|
|
82
|
+
headers: { 'x-forwarded-for': ip },
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Scaffold a fresh temp project with the given window/max and push its schema. Returns the project dir. */
|
|
87
|
+
async function setupProject(window: number, max: number): Promise<string> {
|
|
88
|
+
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'opensaas-ratelimit-e2e-'))
|
|
89
|
+
const dir = path.join(tmpRoot, 'project')
|
|
90
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
91
|
+
|
|
92
|
+
await fsp.writeFile(path.join(dir, 'opensaas.config.ts'), makeConfigSource(window, max))
|
|
93
|
+
await fsp.writeFile(
|
|
94
|
+
path.join(dir, 'package.json'),
|
|
95
|
+
JSON.stringify(
|
|
96
|
+
{ name: 'ratelimit-e2e-project', version: '0.0.0', private: true, type: 'module' },
|
|
97
|
+
null,
|
|
98
|
+
2,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
await fsp.writeFile(path.join(dir, '.env'), 'DATABASE_URL=file:./dev.db\n')
|
|
102
|
+
await fsp.symlink(toolchainNodeModules, path.join(dir, 'node_modules'))
|
|
103
|
+
|
|
104
|
+
const env = {
|
|
105
|
+
...process.env,
|
|
106
|
+
DATABASE_URL: 'file:./dev.db',
|
|
107
|
+
BETTER_AUTH_SECRET: 'e2e-test-secret-not-for-production-0000000000',
|
|
108
|
+
BETTER_AUTH_URL: 'http://localhost:3000',
|
|
109
|
+
}
|
|
110
|
+
const binDir = path.join(dir, 'node_modules', '.bin')
|
|
111
|
+
await run(path.join(binDir, 'opensaas'), ['generate'], { cwd: dir, env })
|
|
112
|
+
await run(path.join(binDir, 'prisma'), ['db', 'push'], { cwd: dir, env })
|
|
113
|
+
|
|
114
|
+
return dir
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Import the temp project's config + generated context and build a real auth
|
|
119
|
+
* instance via `createAuth()`. Each call produces a genuinely separate
|
|
120
|
+
* `betterAuth()` instance — `createAuth()`'s lazy proxy calls `betterAuth()`
|
|
121
|
+
* fresh on first use inside its own closure (see `src/server/index.ts`), so
|
|
122
|
+
* two calls here are two independently-constructed `Auth` objects even when
|
|
123
|
+
* (as here) they resolve through the same imported `rawOpensaasContext`
|
|
124
|
+
* module — itself just the standard Prisma-connection-pooling shape a real
|
|
125
|
+
* app would also share across requests. What distinguishes this from
|
|
126
|
+
* in-memory rate-limit storage is that each `betterAuth()` instance keeps no
|
|
127
|
+
* limiter state of its own — every read/write round-trips through Prisma to
|
|
128
|
+
* the shared on-disk table, which is exactly the property under test.
|
|
129
|
+
*/
|
|
130
|
+
async function createAuthInstanceForProject(dir: string) {
|
|
131
|
+
// The config's `prismaClientConstructor` reads `process.env.DATABASE_URL` at
|
|
132
|
+
// runtime (unlike the `generate`/`db push` subprocesses, which received it
|
|
133
|
+
// via their own `env`). An absolute path avoids it resolving relative to
|
|
134
|
+
// the test process's cwd instead of the temp project dir.
|
|
135
|
+
process.env.DATABASE_URL = `file:${path.join(dir, 'dev.db')}`
|
|
136
|
+
|
|
137
|
+
// `@vite-ignore` suppresses Vite's static analysis of this computed,
|
|
138
|
+
// external (outside the package root) specifier — it isn't something Vite
|
|
139
|
+
// could usefully pre-bundle anyway.
|
|
140
|
+
const config = (
|
|
141
|
+
(await import(/* @vite-ignore */ pathToFileURL(path.join(dir, 'opensaas.config.ts')).href)) as {
|
|
142
|
+
default: OpenSaasConfig | Promise<OpenSaasConfig>
|
|
143
|
+
}
|
|
144
|
+
).default
|
|
145
|
+
const { rawOpensaasContext } = (await import(
|
|
146
|
+
/* @vite-ignore */ pathToFileURL(path.join(dir, '.opensaas/context.ts')).href
|
|
147
|
+
)) as { rawOpensaasContext: Promise<AccessContext> }
|
|
148
|
+
|
|
149
|
+
const resolvedContext = await rawOpensaasContext
|
|
150
|
+
|
|
151
|
+
return { auth: createAuth(config, resolvedContext), context: resolvedContext }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Unlink the borrowed node_modules symlink (never recurse into/delete the real one) and remove the temp root. */
|
|
155
|
+
async function cleanupProject(dir: string): Promise<void> {
|
|
156
|
+
// The generated `.opensaas/context.ts` caches its Prisma client on
|
|
157
|
+
// `globalThis.prisma` outside `NODE_ENV === 'production'` (the standard
|
|
158
|
+
// dev-mode HMR pattern, so a hot reload doesn't open a fresh connection
|
|
159
|
+
// every time). That's process-wide, not per-module — so the NEXT temp
|
|
160
|
+
// project created in this same test process would otherwise inherit THIS
|
|
161
|
+
// one's already-cached client, silently pointed at a temp dir this
|
|
162
|
+
// function is about to delete. Clear it so each project's own
|
|
163
|
+
// `prismaClientConstructor` runs fresh.
|
|
164
|
+
delete (globalThis as { prisma?: unknown }).prisma
|
|
165
|
+
|
|
166
|
+
const linkPath = path.join(dir, 'node_modules')
|
|
167
|
+
if (existsSync(linkPath)) {
|
|
168
|
+
await fsp.unlink(linkPath)
|
|
169
|
+
}
|
|
170
|
+
await fsp.rm(path.dirname(dir), { recursive: true, force: true })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe.skipIf(!prerequisitesPresent)(
|
|
174
|
+
'database-backed rate limiter — live end-to-end (issue #909)',
|
|
175
|
+
() => {
|
|
176
|
+
it('rejects requests once the count exceeds max within the window, against a real generated RateLimit table', async () => {
|
|
177
|
+
const dir = await setupProject(60, 3)
|
|
178
|
+
try {
|
|
179
|
+
const { auth, context } = await createAuthInstanceForProject(dir)
|
|
180
|
+
const ip = '203.0.113.10'
|
|
181
|
+
|
|
182
|
+
const statuses: number[] = []
|
|
183
|
+
for (let i = 0; i < 5; i++) {
|
|
184
|
+
const res = await auth.handler(sessionRequest(ip))
|
|
185
|
+
statuses.push(res.status)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// First `max` (3) requests succeed, everything past it is rejected.
|
|
189
|
+
expect(statuses.slice(0, 3)).toEqual([200, 200, 200])
|
|
190
|
+
expect(statuses.slice(3)).toEqual([429, 429])
|
|
191
|
+
await context.prisma.$disconnect()
|
|
192
|
+
} finally {
|
|
193
|
+
await cleanupProject(dir)
|
|
194
|
+
}
|
|
195
|
+
}, 120_000)
|
|
196
|
+
|
|
197
|
+
it('persists the counter across two separately-constructed auth instances sharing the database', async () => {
|
|
198
|
+
const dir = await setupProject(60, 3)
|
|
199
|
+
try {
|
|
200
|
+
const ip = '198.51.100.20'
|
|
201
|
+
|
|
202
|
+
// Two independently-constructed betterAuth() instances (via two
|
|
203
|
+
// separate createAuth() lazy proxies), sharing one sqlite file —
|
|
204
|
+
// the property in-memory storage does not have.
|
|
205
|
+
const { auth: authA } = await createAuthInstanceForProject(dir)
|
|
206
|
+
const { auth: authB, context } = await createAuthInstanceForProject(dir)
|
|
207
|
+
|
|
208
|
+
expect((await authA.handler(sessionRequest(ip))).status).toBe(200)
|
|
209
|
+
expect((await authA.handler(sessionRequest(ip))).status).toBe(200)
|
|
210
|
+
expect((await authA.handler(sessionRequest(ip))).status).toBe(200)
|
|
211
|
+
|
|
212
|
+
// Instance B, constructed fresh and never having handled a request
|
|
213
|
+
// for this IP, must see A's persisted counter via the database and
|
|
214
|
+
// reject — proof the limiter state lives in the DB, not in-process.
|
|
215
|
+
expect((await authB.handler(sessionRequest(ip))).status).toBe(429)
|
|
216
|
+
await context.prisma.$disconnect()
|
|
217
|
+
} finally {
|
|
218
|
+
await cleanupProject(dir)
|
|
219
|
+
}
|
|
220
|
+
}, 120_000)
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
// Surface, in a normal unit run, why this guard was skipped.
|
|
225
|
+
describe.runIf(!prerequisitesPresent)('database-backed rate limiter e2e (skipped)', () => {
|
|
226
|
+
it('runs only in the e2e job (set RUN_RATE_LIMIT_E2E=1 after install + build)', () => {
|
|
227
|
+
expect(prerequisitesPresent).toBe(false)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
afterAll(() => {
|
|
231
|
+
if (!guardEnabled) return
|
|
232
|
+
// The guard is opted in but its build/toolchain prerequisites are
|
|
233
|
+
// missing — surface why, mirroring the scaffold guard's message.
|
|
234
|
+
console.warn(
|
|
235
|
+
'[rate-limit-e2e] RUN_RATE_LIMIT_E2E=1 was set but prerequisites are missing. ' +
|
|
236
|
+
'Run `pnpm install && pnpm build` (and ensure examples/starter-auth has been installed) first.',
|
|
237
|
+
)
|
|
238
|
+
})
|
|
239
|
+
})
|