@lastshotlabs/bunshot 0.0.10 → 0.0.16
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/README.md +2510 -1580
- package/dist/adapters/memoryAuth.d.ts +4 -0
- package/dist/adapters/memoryAuth.js +131 -2
- package/dist/adapters/mongoAuth.js +56 -0
- package/dist/adapters/sqliteAuth.d.ts +6 -0
- package/dist/adapters/sqliteAuth.js +137 -2
- package/dist/app.d.ts +107 -2
- package/dist/app.js +83 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +15 -5
- package/dist/index.js +10 -3
- package/dist/lib/appConfig.d.ts +46 -0
- package/dist/lib/appConfig.js +20 -0
- package/dist/lib/authAdapter.d.ts +30 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/context.d.ts +2 -0
- package/dist/lib/createDtoMapper.d.ts +33 -0
- package/dist/lib/createDtoMapper.js +69 -0
- package/dist/lib/createRoute.d.ts +61 -0
- package/dist/lib/createRoute.js +147 -0
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +2 -2
- package/dist/lib/mfaChallenge.d.ts +20 -0
- package/dist/lib/mfaChallenge.js +184 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- package/dist/lib/roles.d.ts +4 -0
- package/dist/lib/roles.js +27 -0
- package/dist/lib/session.d.ts +12 -0
- package/dist/lib/session.js +163 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/cacheResponse.js +4 -1
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +5 -2
- package/dist/middleware/requireRole.d.ts +14 -3
- package/dist/middleware/requireRole.js +46 -6
- package/dist/middleware/tenant.d.ts +5 -0
- package/dist/middleware/tenant.js +116 -0
- package/dist/models/AuthUser.d.ts +8 -0
- package/dist/models/AuthUser.js +8 -0
- package/dist/models/TenantRole.d.ts +15 -0
- package/dist/models/TenantRole.js +23 -0
- package/dist/routes/auth.d.ts +5 -3
- package/dist/routes/auth.js +253 -80
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +1 -0
- package/dist/routes/mfa.js +409 -0
- package/dist/routes/oauth.js +107 -16
- package/dist/server.js +9 -0
- package/dist/services/auth.d.ts +21 -2
- package/dist/services/auth.js +97 -17
- package/dist/services/mfa.d.ts +37 -0
- package/dist/services/mfa.js +276 -0
- package/docs/sections/adding-middleware/full.md +35 -0
- package/docs/sections/adding-models/full.md +125 -0
- package/docs/sections/adding-models/overview.md +13 -0
- package/docs/sections/adding-routes/full.md +182 -0
- package/docs/sections/adding-routes/overview.md +23 -0
- package/docs/sections/auth-flow/full.md +456 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +135 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +99 -0
- package/docs/sections/configuration-example/overview.md +30 -0
- package/docs/sections/documentation/full.md +171 -0
- package/docs/sections/environment-variables/full.md +55 -0
- package/docs/sections/exports/full.md +83 -0
- package/docs/sections/extending-context/full.md +59 -0
- package/docs/sections/header.md +3 -0
- package/docs/sections/installation/full.md +6 -0
- package/docs/sections/jobs/full.md +140 -0
- package/docs/sections/jobs/overview.md +15 -0
- package/docs/sections/mongodb-connections/full.md +45 -0
- package/docs/sections/mongodb-connections/overview.md +7 -0
- package/docs/sections/multi-tenancy/full.md +62 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +119 -0
- package/docs/sections/oauth/overview.md +16 -0
- package/docs/sections/package-development/full.md +7 -0
- package/docs/sections/peer-dependencies/full.md +43 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +115 -0
- package/docs/sections/response-caching/overview.md +13 -0
- package/docs/sections/roles/full.md +136 -0
- package/docs/sections/roles/overview.md +12 -0
- package/docs/sections/running-without-redis/full.md +16 -0
- package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
- package/docs/sections/stack/full.md +10 -0
- package/docs/sections/websocket/full.md +100 -0
- package/docs/sections/websocket/overview.md +5 -0
- package/docs/sections/websocket-rooms/full.md +97 -0
- package/docs/sections/websocket-rooms/overview.md +5 -0
- package/package.json +19 -10
package/README.md
CHANGED
|
@@ -1,1580 +1,2510 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
```ts
|
|
283
|
-
|
|
284
|
-
import
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
import
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
export
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
`
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
},
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
```
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
import {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
```
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
};
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
});
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
},
|
|
789
|
-
},
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
```
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
router.use("/
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
router.use("/
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
```
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
},
|
|
1178
|
-
})
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
//
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
###
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1
|
+
<!-- AUTO-GENERATED — edit docs/sections/, not this file. Run: bun run readme -->
|
|
2
|
+
|
|
3
|
+
# Bunshot by Last Shot Labs
|
|
4
|
+
|
|
5
|
+
A personal Bun + Hono API framework. Install it in any app and get auth, sessions, rate limiting, WebSocket, queues, and OpenAPI docs out of the box — then add your own routes, workers, models, and services.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @lastshotlabs/bunshot hono zod
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// src/index.ts
|
|
15
|
+
import { createServer } from "@lastshotlabs/bunshot";
|
|
16
|
+
|
|
17
|
+
await createServer({
|
|
18
|
+
routesDir: import.meta.dir + "/routes",
|
|
19
|
+
db: { auth: "memory", mongo: false, redis: false, sessions: "memory", cache: "memory" },
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// src/routes/hello.ts
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { createRoute, createRouter } from "@lastshotlabs/bunshot";
|
|
27
|
+
|
|
28
|
+
export const router = createRouter();
|
|
29
|
+
|
|
30
|
+
router.openapi(
|
|
31
|
+
createRoute({
|
|
32
|
+
method: "get",
|
|
33
|
+
path: "/hello",
|
|
34
|
+
responses: {
|
|
35
|
+
200: {
|
|
36
|
+
content: { "application/json": { schema: z.object({ message: z.string() }) } },
|
|
37
|
+
description: "Hello",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
(c) => c.json({ message: "Hello world!" }, 200)
|
|
42
|
+
);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bun run src/index.ts
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Auth, OpenAPI docs (`/docs`), health check, and WebSocket are all live. No databases required — swap `"memory"` for `"redis"` / `"mongo"` / `"sqlite"` when you're ready.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Stack
|
|
54
|
+
|
|
55
|
+
- **Runtime**: [Bun](https://bun.sh)
|
|
56
|
+
- **Framework**: [Hono](https://hono.dev) + [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi)
|
|
57
|
+
- **Docs UI**: [Scalar](https://scalar.com)
|
|
58
|
+
- **Data / Auth**: MongoDB, SQLite, or in-memory — configurable via `db.auth` (default: MongoDB via [Mongoose](https://mongoosejs.com))
|
|
59
|
+
- **Cache / Sessions**: Redis, MongoDB, SQLite, or in-memory — configurable via `db.sessions` / `db.cache` (default: Redis via [ioredis](https://github.com/redis/ioredis))
|
|
60
|
+
- **Auth**: JWT via [jose](https://github.com/panva/jose), HttpOnly cookies + `x-user-token` header
|
|
61
|
+
- **Queues**: [BullMQ](https://docs.bullmq.io) (requires Redis with `noeviction` policy)
|
|
62
|
+
- **Validation**: [Zod v4](https://zod.dev)
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## CLI — Scaffold a New Project
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
bunx @lastshotlabs/bunshot "My App"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
You can also pass a custom directory name:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
bunx @lastshotlabs/bunshot "My App" my-app-dir
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This creates a ready-to-run project with:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
my-app/
|
|
82
|
+
src/
|
|
83
|
+
index.ts # entry point
|
|
84
|
+
config/index.ts # centralized app configuration
|
|
85
|
+
lib/constants.ts # app name, version, roles
|
|
86
|
+
routes/ # add your route files here
|
|
87
|
+
workers/ # BullMQ workers (auto-discovered)
|
|
88
|
+
middleware/ # custom middleware
|
|
89
|
+
models/ # data models
|
|
90
|
+
services/ # business logic
|
|
91
|
+
tsconfig.json # pre-configured with path aliases
|
|
92
|
+
.env # environment variables template
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Path aliases like `@config/*`, `@lib/*`, `@middleware/*`, `@models/*`, `@routes/*`, `@services/*`, and `@workers/*` are set up automatically in `tsconfig.json`.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Installation
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# from npm
|
|
103
|
+
bun add @lastshotlabs/bunshot
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Full Configuration Example
|
|
109
|
+
|
|
110
|
+
For production apps, break config into its own file. Here's a real-world setup with MongoDB, Redis, OAuth, and email verification:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// src/config/index.ts
|
|
114
|
+
import path from "path";
|
|
115
|
+
import {
|
|
116
|
+
type CreateServerConfig,
|
|
117
|
+
type AppMeta,
|
|
118
|
+
type AuthConfig,
|
|
119
|
+
type DbConfig,
|
|
120
|
+
type SecurityConfig,
|
|
121
|
+
type ModelSchemasConfig,
|
|
122
|
+
} from "@lastshotlabs/bunshot";
|
|
123
|
+
|
|
124
|
+
const app: AppMeta = {
|
|
125
|
+
name: "My App",
|
|
126
|
+
version: "1.0.0",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const db: DbConfig = {
|
|
130
|
+
mongo: "single", // "single" | "separate" | false
|
|
131
|
+
redis: true, // false to skip Redis
|
|
132
|
+
sessions: "redis", // "redis" | "mongo" | "sqlite" | "memory"
|
|
133
|
+
cache: "memory", // default store for cacheResponse
|
|
134
|
+
auth: "mongo", // "mongo" | "sqlite" | "memory"
|
|
135
|
+
oauthState: "memory", // where to store OAuth state tokens
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const auth: AuthConfig = {
|
|
139
|
+
roles: ["admin", "user"],
|
|
140
|
+
defaultRole: "user",
|
|
141
|
+
primaryField: "email",
|
|
142
|
+
rateLimit: { store: "redis" },
|
|
143
|
+
emailVerification: {
|
|
144
|
+
required: true,
|
|
145
|
+
tokenExpiry: 60 * 60, // 1 hour
|
|
146
|
+
onSend: async (email, token) => {
|
|
147
|
+
// send verification email using any provider (Resend, SES, etc.)
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
oauth: {
|
|
151
|
+
postRedirect: "http://localhost:5175/oauth/callback",
|
|
152
|
+
providers: {
|
|
153
|
+
google: {
|
|
154
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
155
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
156
|
+
redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/google/callback`,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const security: SecurityConfig = {
|
|
163
|
+
bearerAuth: true,
|
|
164
|
+
cors: ["*", "http://localhost:5173"],
|
|
165
|
+
botProtection: { fingerprintRateLimit: true },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const modelSchemas: ModelSchemasConfig = {
|
|
169
|
+
registration: "auto",
|
|
170
|
+
paths: [path.join(import.meta.dir, "../schemas/*.ts")],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const appConfig: CreateServerConfig = {
|
|
174
|
+
app,
|
|
175
|
+
routesDir: path.join(import.meta.dir, "../routes"),
|
|
176
|
+
workersDir: path.join(import.meta.dir, "../workers"),
|
|
177
|
+
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
|
|
178
|
+
db,
|
|
179
|
+
auth,
|
|
180
|
+
security,
|
|
181
|
+
modelSchemas,
|
|
182
|
+
middleware: [/* your global middleware here */],
|
|
183
|
+
};
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Every field above is optional except `routesDir`. See the [Configuration](#configuration) section for the full reference.
|
|
187
|
+
|
|
188
|
+
### Built-in endpoints
|
|
189
|
+
|
|
190
|
+
| Endpoint | Description |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `POST /auth/register` | Create account, returns JWT |
|
|
193
|
+
| `POST /auth/login` | Login, returns JWT (includes `emailVerified` when verification is configured) |
|
|
194
|
+
| `POST /auth/logout` | Invalidates the current session only |
|
|
195
|
+
| `GET /auth/me` | Returns current user's `userId`, `email`, `emailVerified`, and `googleLinked` (requires login) |
|
|
196
|
+
| `POST /auth/set-password` | Set or update password (requires login) |
|
|
197
|
+
| `GET /auth/sessions` | List active sessions with metadata — IP, user-agent, timestamps (requires login) |
|
|
198
|
+
| `DELETE /auth/sessions/:sessionId` | Revoke a specific session by ID (requires login) |
|
|
199
|
+
| `POST /auth/verify-email` | Verify email with token (when `emailVerification` is configured) |
|
|
200
|
+
| `POST /auth/resend-verification` | Resend verification email (requires credentials, when `emailVerification` is configured) |
|
|
201
|
+
| `POST /auth/forgot-password` | Request a password reset email (when `passwordReset` is configured) |
|
|
202
|
+
| `POST /auth/reset-password` | Reset password using a token from the reset email (when `passwordReset` is configured) |
|
|
203
|
+
| `GET /health` | Health check |
|
|
204
|
+
| `GET /docs` | Scalar API docs UI |
|
|
205
|
+
| `GET /openapi.json` | OpenAPI spec |
|
|
206
|
+
| `WS /ws` | WebSocket endpoint (cookie-JWT auth) |
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Adding Routes
|
|
211
|
+
|
|
212
|
+
Drop a file in your `routes/` directory that exports a `router` — see the [Quick Start](#quick-start) example above. Routes are auto-discovered via glob — no registration needed. Subdirectories are supported, so you can organise by feature:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
routes/
|
|
216
|
+
products.ts
|
|
217
|
+
ingredients/
|
|
218
|
+
list.ts
|
|
219
|
+
detail.ts
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### OpenAPI Schema Registration
|
|
223
|
+
|
|
224
|
+
Import `createRoute` from `@lastshotlabs/bunshot` (not from `@hono/zod-openapi`). The wrapper automatically registers every unnamed request body and response schema as a named entry in `components/schemas`. Schemas you already named via `registerSchema` are never overwritten.
|
|
225
|
+
|
|
226
|
+
Every Zod schema that appears in your OpenAPI spec ends up as a named entry in `components/schemas` — either auto-named by the framework or explicitly named by you. There are four registration methods, each suited to a different scenario.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
### Method 1 — Route-level auto-registration (via `createRoute`)
|
|
231
|
+
|
|
232
|
+
The most common case. When you define a route with `createRoute`, every unnamed request body and response schema is automatically registered under a name derived from the HTTP method and path.
|
|
233
|
+
|
|
234
|
+
**Naming convention**
|
|
235
|
+
|
|
236
|
+
| Route | Part | Generated name |
|
|
237
|
+
|-------|------|----------------|
|
|
238
|
+
| `POST /products` | request body | `CreateProductsRequest` |
|
|
239
|
+
| `POST /products` | 201 response | `CreateProductsResponse` |
|
|
240
|
+
| `GET /products/{id}` | 200 response | `GetProductsByIdResponse` |
|
|
241
|
+
| `DELETE /products/{id}` | 404 response | `DeleteProductsByIdNotFoundError` |
|
|
242
|
+
| `PATCH /products/{id}` | request body | `UpdateProductsByIdRequest` |
|
|
243
|
+
|
|
244
|
+
HTTP methods → verbs: `GET → Get`, `POST → Create`, `PUT → Replace`, `PATCH → Update`, `DELETE → Delete`.
|
|
245
|
+
|
|
246
|
+
Status codes → suffixes: `200/201/204 → Response`, `400 → BadRequestError`, `401 → UnauthorizedError`, `403 → ForbiddenError`, `404 → NotFoundError`, `409 → ConflictError`, `422 → ValidationError`, `429 → RateLimitError`, `500 → InternalError`, `501 → NotImplementedError`, `503 → UnavailableError`. Unknown codes fall back to the number.
|
|
247
|
+
|
|
248
|
+
**Limitation:** if the same Zod object is used in two different routes, each route names it after itself — you get two identical inline shapes instead of one shared `$ref`. Use Method 2 or 3 to fix this.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
### Method 2 — Directory / glob auto-discovery (via `modelSchemas`)
|
|
253
|
+
|
|
254
|
+
Use this when you have schemas shared across multiple routes. Point `modelSchemas` at one or more directories and Bunshot imports every `.ts` file **before** routes are loaded. Any exported Zod schema is registered automatically — same object referenced in multiple routes → same `$ref` in the spec.
|
|
255
|
+
|
|
256
|
+
**Naming:** export name with the trailing `Schema` suffix stripped (`LedgerItemSchema` → `"LedgerItem"`). Already-registered schemas are never overwritten.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
// src/schemas/ledgerItem.ts
|
|
260
|
+
import { z } from "zod";
|
|
261
|
+
export const LedgerItemSchema = z.object({ id: z.string(), name: z.string(), amount: z.number() });
|
|
262
|
+
// → auto-registered as "LedgerItem"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// src/config/index.ts
|
|
267
|
+
await createServer({
|
|
268
|
+
routesDir: import.meta.dir + "/routes",
|
|
269
|
+
modelSchemas: import.meta.dir + "/schemas", // string shorthand — registration: "auto"
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
// src/routes/ledger.ts AND src/routes/ledgerDetail.ts
|
|
275
|
+
import { LedgerItemSchema } from "@schemas/ledgerItem"; // same Zod object instance
|
|
276
|
+
createRoute({ responses: { 200: { content: { "application/json": { schema: LedgerItemSchema } } } } });
|
|
277
|
+
// → $ref: "#/components/schemas/LedgerItem" in both routes
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Multiple directories and glob patterns**
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
modelSchemas: [
|
|
284
|
+
import.meta.dir + "/schemas", // dedicated schemas dir
|
|
285
|
+
import.meta.dir + "/models", // co-located with DB models
|
|
286
|
+
import.meta.dir + "/services/**/*.schema.ts", // selective glob
|
|
287
|
+
]
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Full config object** — use when you need to set `registration` or mix paths and globs:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
modelSchemas: {
|
|
294
|
+
paths: [import.meta.dir + "/schemas", import.meta.dir + "/models"],
|
|
295
|
+
registration: "auto", // default — auto-registers exports with suffix stripping
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**`registration: "explicit"`** — files are imported but nothing is auto-registered. Registration is left entirely to `registerSchema` / `registerSchemas` calls inside each file. Use this when you want zero magic and full name control:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
modelSchemas: { paths: import.meta.dir + "/schemas", registration: "explicit" }
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
### Method 3 — Batch explicit registration (via `registerSchemas`)
|
|
308
|
+
|
|
309
|
+
`registerSchemas` lets you name a group of schemas all at once. Object keys become the `components/schemas` names; the same object is returned so you can destructure and export normally. No suffix stripping — names are taken as-is.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// src/schemas/index.ts
|
|
313
|
+
import { registerSchemas } from "@lastshotlabs/bunshot";
|
|
314
|
+
import { z } from "zod";
|
|
315
|
+
|
|
316
|
+
export const { LedgerItem, Product, ErrorResponse } = registerSchemas({
|
|
317
|
+
LedgerItem: z.object({ id: z.string(), name: z.string(), amount: z.number() }),
|
|
318
|
+
Product: z.object({ id: z.string(), price: z.number() }),
|
|
319
|
+
ErrorResponse: z.object({ error: z.string() }),
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Pair with `registration: "explicit"` in `modelSchemas` so the file is imported before routes, or call it inline at the top of any route file — route files are auto-discovered so the top-level call runs before the spec is served.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
### Method 4 — Single explicit registration (via `registerSchema`)
|
|
328
|
+
|
|
329
|
+
`registerSchema("Name", schema)` registers one schema and returns it unchanged. Useful for a single shared type (e.g. a common error envelope) or to override the name auto-discovery would generate.
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
// src/schemas/errors.ts
|
|
333
|
+
import { registerSchema } from "@lastshotlabs/bunshot";
|
|
334
|
+
import { z } from "zod";
|
|
335
|
+
|
|
336
|
+
export const ErrorResponse = registerSchema("ErrorResponse",
|
|
337
|
+
z.object({ error: z.string() })
|
|
338
|
+
);
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Registration is idempotent — calling `registerSchema` on an already-registered schema is a no-op. This means you can safely call it in files that are also covered by `modelSchemas` auto-discovery: whichever runs first wins, and the other is silently skipped.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
### Priority and interaction
|
|
346
|
+
|
|
347
|
+
All four methods write to the same process-global registry. The rules are simple:
|
|
348
|
+
|
|
349
|
+
1. **First write wins** — once a schema has a name, it cannot be renamed.
|
|
350
|
+
2. **`modelSchemas` files are imported before routes**, so explicit calls inside them always take precedence over what `createRoute` would generate for the same object.
|
|
351
|
+
3. **`registerSchema` / `registerSchemas` take precedence over auto-discovery** when they appear at module top level (they run at import time, before `maybeAutoRegister` inspects the export list).
|
|
352
|
+
4. **`createRoute` never overwrites** a schema already in the registry — it only fills gaps.
|
|
353
|
+
|
|
354
|
+
**Decision guide:**
|
|
355
|
+
|
|
356
|
+
| Situation | Use |
|
|
357
|
+
|-----------|-----|
|
|
358
|
+
| Route-specific, one-off schema | `createRoute` auto-registration (Method 1) |
|
|
359
|
+
| Shared across routes, happy with suffix-stripped export name | `modelSchemas` auto-discovery (Method 2) |
|
|
360
|
+
| Shared across routes, want explicit names or batch control | `registerSchemas` (Method 3) |
|
|
361
|
+
| Single shared schema or custom name override | `registerSchema` (Method 4) |
|
|
362
|
+
|
|
363
|
+
**Protected routes**
|
|
364
|
+
|
|
365
|
+
Use `withSecurity` to declare security schemes on a route without breaking `c.req.valid()` type inference. (Inlining `security` directly in `createRoute({...})` causes TypeScript to collapse the handler's input types to `never`.)
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
import { createRoute, withSecurity } from "@lastshotlabs/bunshot";
|
|
369
|
+
|
|
370
|
+
router.openapi(
|
|
371
|
+
withSecurity(
|
|
372
|
+
createRoute({ method: "get", path: "/me", ... }),
|
|
373
|
+
{ cookieAuth: [] },
|
|
374
|
+
{ userToken: [] }
|
|
375
|
+
),
|
|
376
|
+
async (c) => {
|
|
377
|
+
const userId = c.get("authUserId"); // fully typed
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Pass each security scheme as a separate object argument. The security scheme names (`cookieAuth`, `userToken`, `bearerAuth`) are registered globally by `createApp`.
|
|
383
|
+
|
|
384
|
+
**Load order:** By default, routes load in filesystem order. If a route needs to be registered before another (e.g. for Hono's first-match-wins routing), export a `priority` number — lower values load first. Routes without a `priority` load last.
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
// routes/tenants.ts — must match before generic routes
|
|
388
|
+
export const priority = 1;
|
|
389
|
+
export const router = createRouter();
|
|
390
|
+
// ...
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## MongoDB Connections
|
|
396
|
+
|
|
397
|
+
MongoDB and Redis connect automatically inside `createServer` / `createApp`. Control the behavior via the `db` config object:
|
|
398
|
+
|
|
399
|
+
### Single database (default)
|
|
400
|
+
|
|
401
|
+
Both auth and app data share one server. Uses `MONGO_*` env vars.
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
await createServer({
|
|
405
|
+
// ...
|
|
406
|
+
db: { mongo: "single", redis: true }, // these are the defaults — can omit db entirely
|
|
407
|
+
// app, auth, security are all optional with sensible defaults
|
|
408
|
+
});
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Separate auth database
|
|
412
|
+
|
|
413
|
+
Auth users live on a dedicated server (`MONGO_AUTH_*` env vars), app data on its own server (`MONGO_*` env vars). Useful when multiple tenant apps share one auth cluster.
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
await createServer({
|
|
417
|
+
// ...
|
|
418
|
+
db: { mongo: "separate" },
|
|
419
|
+
});
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Manual connections
|
|
423
|
+
|
|
424
|
+
Set `mongo: false` and/or `redis: false` to skip auto-connect and manage connections yourself:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
import { connectAuthMongo, connectAppMongo, connectRedis, createServer } from "@lastshotlabs/bunshot";
|
|
428
|
+
|
|
429
|
+
await connectAuthMongo();
|
|
430
|
+
await connectAppMongo();
|
|
431
|
+
await connectRedis();
|
|
432
|
+
|
|
433
|
+
await createServer({
|
|
434
|
+
// ...
|
|
435
|
+
db: { mongo: false, redis: false },
|
|
436
|
+
});
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
`AuthUser` and all built-in auth routes always use `authConnection`. Your app models use `appConnection` (see Adding Models below).
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Adding Models
|
|
444
|
+
|
|
445
|
+
Import `appConnection` and register models on it. This ensures your models use the correct connection whether you're on a single DB or a separate tenant DB.
|
|
446
|
+
|
|
447
|
+
`appConnection` is a lazy proxy — calling `.model()` at the top level works fine even before `connectMongo()` has been called. Mongoose buffers any queries until the connection is established.
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
// src/models/Product.ts
|
|
451
|
+
import { appConnection } from "@lastshotlabs/bunshot";
|
|
452
|
+
import { Schema } from "mongoose";
|
|
453
|
+
import type { HydratedDocument } from "mongoose";
|
|
454
|
+
|
|
455
|
+
interface IProduct {
|
|
456
|
+
name: string;
|
|
457
|
+
price: number;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export type ProductDocument = HydratedDocument<IProduct>;
|
|
461
|
+
|
|
462
|
+
const ProductSchema = new Schema<IProduct>({
|
|
463
|
+
name: { type: String, required: true },
|
|
464
|
+
price: { type: Number, required: true },
|
|
465
|
+
}, { timestamps: true });
|
|
466
|
+
|
|
467
|
+
export const Product = appConnection.model<IProduct>("Product", ProductSchema);
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
> **Note:** Import types (`HydratedDocument`, `Schema`, etc.) directly from `"mongoose"` — the `appConnection` and `mongoose` exports from bunshot are runtime proxies and cannot be used as TypeScript namespaces.
|
|
471
|
+
|
|
472
|
+
### Zod as Single Source of Truth
|
|
473
|
+
|
|
474
|
+
If you use Zod schemas for your OpenAPI spec (via `createRoute` or `modelSchemas`), you can derive your Mongoose schemas and DTO mappers from those same Zod definitions — so each entity is defined **once**.
|
|
475
|
+
|
|
476
|
+
#### `zodToMongoose` — Zod → Mongoose SchemaDefinition
|
|
477
|
+
|
|
478
|
+
Converts a Zod object schema into a Mongoose field definition. Business fields are auto-converted; DB-specific concerns (ObjectId refs, type overrides, subdocuments) are declared via config. The `id` field is automatically excluded since Mongoose provides `_id`.
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
import { appConnection, zodToMongoose } from "@lastshotlabs/bunshot";
|
|
482
|
+
import { Schema, type HydratedDocument } from "mongoose";
|
|
483
|
+
import { ProductSchema } from "../schemas/product"; // your Zod schema
|
|
484
|
+
import type { ProductDto } from "../schemas/product";
|
|
485
|
+
|
|
486
|
+
// DB interface derives from Zod DTO type
|
|
487
|
+
interface IProduct extends Omit<ProductDto, "id" | "categoryId"> {
|
|
488
|
+
user: Types.ObjectId;
|
|
489
|
+
category: Types.ObjectId;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const ProductMongoSchema = new Schema<IProduct>(
|
|
493
|
+
zodToMongoose(ProductSchema, {
|
|
494
|
+
dbFields: {
|
|
495
|
+
user: { type: Schema.Types.ObjectId, ref: "UserProfile", required: true },
|
|
496
|
+
},
|
|
497
|
+
refs: {
|
|
498
|
+
categoryId: { dbField: "category", ref: "Category" },
|
|
499
|
+
},
|
|
500
|
+
typeOverrides: {
|
|
501
|
+
createdAt: { type: Date, required: true },
|
|
502
|
+
},
|
|
503
|
+
}) as Record<string, unknown>,
|
|
504
|
+
{ timestamps: true }
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
export type ProductDocument = HydratedDocument<IProduct>;
|
|
508
|
+
export const Product = appConnection.model<IProduct>("Product", ProductMongoSchema);
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
**Config options:**
|
|
512
|
+
|
|
513
|
+
| Option | Description |
|
|
514
|
+
|---|---|
|
|
515
|
+
| `dbFields` | Fields that exist only in the DB, not in the API schema (e.g., `user` ObjectId ref) |
|
|
516
|
+
| `refs` | API fields that map to ObjectId refs: `{ accountId: { dbField: "account", ref: "Account" } }` |
|
|
517
|
+
| `typeOverrides` | Override the auto-converted Mongoose type for a field (e.g., Zod `z.string()` for dates → Mongoose `Date`) |
|
|
518
|
+
| `subdocSchemas` | Subdocument array fields: `{ items: mongooseSubSchema }` |
|
|
519
|
+
|
|
520
|
+
**Auto-conversion mapping:**
|
|
521
|
+
|
|
522
|
+
| Zod type | Mongoose type |
|
|
523
|
+
|---|---|
|
|
524
|
+
| `z.string()` | `String` |
|
|
525
|
+
| `z.number()` | `Number` |
|
|
526
|
+
| `z.boolean()` | `Boolean` |
|
|
527
|
+
| `z.date()` | `Date` |
|
|
528
|
+
| `z.enum([...])` | `String` with `enum` |
|
|
529
|
+
| `.nullable()` / `.optional()` | `required: false` |
|
|
530
|
+
|
|
531
|
+
#### `createDtoMapper` — Zod → toDto mapper
|
|
532
|
+
|
|
533
|
+
Creates a generic `toDto` function from a Zod schema. The schema defines which fields exist in the DTO; the config declares how to transform DB-specific types.
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
import { createDtoMapper } from "@lastshotlabs/bunshot";
|
|
537
|
+
import { ProductSchema, type ProductDto } from "../schemas/product";
|
|
538
|
+
|
|
539
|
+
const toDto = createDtoMapper<ProductDto>(ProductSchema, {
|
|
540
|
+
refs: { category: "categoryId" }, // ObjectId ref → string, with rename
|
|
541
|
+
dates: ["createdAt"], // Date → ISO string
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Use it
|
|
545
|
+
const product = await Product.findOne({ _id: id });
|
|
546
|
+
return product ? toDto(product) : null;
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
**Auto-handled transforms:**
|
|
550
|
+
|
|
551
|
+
| Transform | Description |
|
|
552
|
+
|---|---|
|
|
553
|
+
| `_id` → `id` | Always converted via `.toString()` |
|
|
554
|
+
| `refs` | ObjectId fields → string (`.toString()`), with DB→API field renaming |
|
|
555
|
+
| `dates` | `Date` objects → ISO strings (`.toISOString()`) |
|
|
556
|
+
| `subdocs` | Array fields mapped with a sub-mapper (for nested documents) |
|
|
557
|
+
| nullable/optional | `undefined` → `null` coercion (based on Zod schema) |
|
|
558
|
+
| everything else | Passthrough |
|
|
559
|
+
|
|
560
|
+
**Subdocument example:**
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
const itemToDto = createDtoMapper<TemplateItemDto>(TemplateItemSchema);
|
|
564
|
+
const toDto = createDtoMapper<TemplateDto>(TemplateSchema, {
|
|
565
|
+
subdocs: { items: itemToDto },
|
|
566
|
+
});
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## Jobs (BullMQ)
|
|
572
|
+
|
|
573
|
+
> **Redis requirement**: BullMQ requires `maxmemory-policy noeviction`. Set it in `redis.conf` or via Docker:
|
|
574
|
+
> ```yaml
|
|
575
|
+
> command: redis-server --maxmemory-policy noeviction
|
|
576
|
+
> ```
|
|
577
|
+
|
|
578
|
+
Queues and workers share the existing Redis connection automatically.
|
|
579
|
+
|
|
580
|
+
### Define a queue
|
|
581
|
+
|
|
582
|
+
```ts
|
|
583
|
+
// src/queues/email.ts
|
|
584
|
+
import { createQueue } from "@lastshotlabs/bunshot";
|
|
585
|
+
|
|
586
|
+
export type EmailJob = { to: string; subject: string; body: string };
|
|
587
|
+
|
|
588
|
+
export const emailQueue = createQueue<EmailJob>("email");
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### Add jobs
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
import { emailQueue } from "../queues/email";
|
|
595
|
+
|
|
596
|
+
await emailQueue.add("send-welcome", { to: "user@example.com", subject: "Welcome", body: "..." });
|
|
597
|
+
|
|
598
|
+
// with options
|
|
599
|
+
await emailQueue.add("send-reset", payload, { delay: 5000, attempts: 3 });
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Define a worker
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
// src/workers/email.ts
|
|
606
|
+
import { createWorker } from "@lastshotlabs/bunshot";
|
|
607
|
+
import type { EmailJob } from "../queues/email";
|
|
608
|
+
|
|
609
|
+
export const emailWorker = createWorker<EmailJob>("email", async (job) => {
|
|
610
|
+
const { to, subject, body } = job.data;
|
|
611
|
+
// send email...
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Workers in `workersDir` are auto-discovered and registered after the server starts — no manual imports needed. Subdirectories are supported.
|
|
616
|
+
|
|
617
|
+
### Broadcasting WebSocket messages from a worker
|
|
618
|
+
|
|
619
|
+
Use `publish` to broadcast to all connected clients from inside a worker (or anywhere):
|
|
620
|
+
|
|
621
|
+
```ts
|
|
622
|
+
// src/workers/notify.ts
|
|
623
|
+
import { createWorker, publish } from "@lastshotlabs/bunshot";
|
|
624
|
+
import type { NotifyJob } from "../queues/notify";
|
|
625
|
+
|
|
626
|
+
export const notifyWorker = createWorker<NotifyJob>("notify", async (job) => {
|
|
627
|
+
const { text, from } = job.data;
|
|
628
|
+
publish("broadcast", { text, from, timestamp: new Date().toISOString() });
|
|
629
|
+
});
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
`publish` is available after `createServer` resolves. Workers are loaded after that point, so it's always safe to use inside a worker.
|
|
633
|
+
|
|
634
|
+
### Cron / scheduled workers
|
|
635
|
+
|
|
636
|
+
Use `createCronWorker` for recurring jobs. It creates both a queue and worker, and uses BullMQ's `upsertJobScheduler` for idempotent scheduling across restarts.
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
// src/workers/cleanup.ts
|
|
640
|
+
import { createCronWorker } from "@lastshotlabs/bunshot/queue";
|
|
641
|
+
|
|
642
|
+
export const { worker, queue } = createCronWorker(
|
|
643
|
+
"cleanup",
|
|
644
|
+
async (job) => {
|
|
645
|
+
// runs every hour
|
|
646
|
+
await deleteExpiredRecords();
|
|
647
|
+
},
|
|
648
|
+
{ cron: "0 * * * *" } // or { every: 3_600_000 } for interval-based
|
|
649
|
+
);
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
**Ghost job cleanup**: When a cron worker is renamed or removed, the old scheduler persists in Redis. Bunshot handles this automatically — after all workers in `workersDir` are loaded, stale schedulers are pruned. For workers managed outside `workersDir`, call `cleanupStaleSchedulers(activeNames)` manually.
|
|
653
|
+
|
|
654
|
+
### Job status endpoint
|
|
655
|
+
|
|
656
|
+
Expose job state via REST for client-side polling (e.g., long-running uploads or exports):
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
import { userAuth, requireRole } from "@lastshotlabs/bunshot";
|
|
660
|
+
|
|
661
|
+
await createServer({
|
|
662
|
+
jobs: {
|
|
663
|
+
statusEndpoint: true, // default: false
|
|
664
|
+
auth: "userAuth", // "userAuth" | "none" | MiddlewareHandler[]
|
|
665
|
+
roles: ["admin"], // require these roles (works with userAuth)
|
|
666
|
+
allowedQueues: ["export", "upload"], // whitelist — empty = nothing exposed (secure by default)
|
|
667
|
+
scopeToUser: false, // when true with userAuth, users only see their own jobs
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
**Auth options:**
|
|
673
|
+
- `"userAuth"` — requires an authenticated user session. Combine with `roles` for RBAC.
|
|
674
|
+
- `"none"` — no auth protection (not recommended for production).
|
|
675
|
+
- `MiddlewareHandler[]` — pass a custom middleware stack for full control, e.g. `[userAuth, requireRole("admin")]`.
|
|
676
|
+
|
|
677
|
+
#### Endpoints
|
|
678
|
+
|
|
679
|
+
| Endpoint | Purpose |
|
|
680
|
+
|---|---|
|
|
681
|
+
| `GET /jobs` | List available queues |
|
|
682
|
+
| `GET /jobs/:queue` | List jobs in a queue (paginated, filterable by state) |
|
|
683
|
+
| `GET /jobs/:queue/:id` | Job state, progress, result, or failure reason |
|
|
684
|
+
| `GET /jobs/:queue/:id/logs` | Job logs |
|
|
685
|
+
| `GET /jobs/:queue/dead-letters` | Paginated list of DLQ jobs |
|
|
686
|
+
|
|
687
|
+
The list endpoint (`GET /jobs/:queue`) accepts `?state=waiting|active|completed|failed|delayed|paused` and `?start=0&end=19` for pagination.
|
|
688
|
+
|
|
689
|
+
### Dead Letter Queue (DLQ)
|
|
690
|
+
|
|
691
|
+
Automatically move permanently failed jobs to a DLQ for inspection and retry:
|
|
692
|
+
|
|
693
|
+
```ts
|
|
694
|
+
import { createWorker, createDLQHandler } from "@lastshotlabs/bunshot/queue";
|
|
695
|
+
|
|
696
|
+
const emailWorker = createWorker("email", async (job) => { ... });
|
|
697
|
+
|
|
698
|
+
const { dlqQueue, retryJob } = createDLQHandler(emailWorker, "email", {
|
|
699
|
+
maxSize: 1000, // default: 1000 — oldest trimmed when exceeded
|
|
700
|
+
onDeadLetter: async (job, error) => { // optional alerting callback
|
|
701
|
+
await alertSlack(`Job ${job.id} failed: ${error.message}`);
|
|
702
|
+
},
|
|
703
|
+
preserveJobOptions: true, // default: true — retry with original delay/priority/attempts
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// Retry a specific failed job
|
|
707
|
+
await retryJob("job-id-123");
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
The DLQ queue is named `${sourceQueueName}-dlq` (e.g., `email-dlq`). It's automatically available via the job status endpoint if listed in `allowedQueues`.
|
|
711
|
+
|
|
712
|
+
---
|
|
713
|
+
|
|
714
|
+
## WebSocket
|
|
715
|
+
|
|
716
|
+
The `/ws` endpoint is mounted automatically by `createServer`. No extra setup needed.
|
|
717
|
+
|
|
718
|
+
### Default behaviour
|
|
719
|
+
|
|
720
|
+
| What | Default |
|
|
721
|
+
|---|---|
|
|
722
|
+
| Upgrade / auth | Reads `auth-token` cookie → verifies JWT → checks session → sets `ws.data.userId` |
|
|
723
|
+
| `open` | Logs connection, sends `{ event: "connected", id }` |
|
|
724
|
+
| `message` | Handles room actions (see below), echoes everything else |
|
|
725
|
+
| `close` | Clears `ws.data.rooms`, logs disconnection |
|
|
726
|
+
|
|
727
|
+
### Socket data (`SocketData`)
|
|
728
|
+
|
|
729
|
+
`SocketData` is generic — pass a type parameter to add your own fields:
|
|
730
|
+
|
|
731
|
+
```ts
|
|
732
|
+
type SocketData<T extends object = object> = {
|
|
733
|
+
id: string; // unique connection ID (UUID)
|
|
734
|
+
userId: string | null; // null if unauthenticated
|
|
735
|
+
rooms: Set<string>; // rooms this socket is subscribed to
|
|
736
|
+
} & T;
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
**Extending with custom fields:**
|
|
740
|
+
|
|
741
|
+
```ts
|
|
742
|
+
import { createServer, type SocketData } from "@lastshotlabs/bunshot";
|
|
743
|
+
|
|
744
|
+
type MyData = { tenantId: string; role: "admin" | "user" };
|
|
745
|
+
|
|
746
|
+
await createServer<MyData>({
|
|
747
|
+
ws: {
|
|
748
|
+
upgradeHandler: async (req, server) => {
|
|
749
|
+
const tenantId = req.headers.get("x-tenant-id") ?? "default";
|
|
750
|
+
const upgraded = server.upgrade(req, {
|
|
751
|
+
data: { id: crypto.randomUUID(), userId: null, rooms: new Set(), tenantId, role: "user" },
|
|
752
|
+
});
|
|
753
|
+
return upgraded ? undefined : Response.json({ error: "Upgrade failed" }, { status: 400 });
|
|
754
|
+
},
|
|
755
|
+
handler: {
|
|
756
|
+
open(ws) {
|
|
757
|
+
// ws.data.tenantId and ws.data.role are fully typed
|
|
758
|
+
console.log(ws.data.tenantId, ws.data.role);
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
onRoomSubscribe(ws, room) {
|
|
762
|
+
return ws.data.role === "admin" || !room.startsWith("admin:");
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
With no type parameter, `SocketData` defaults to `{ id, userId, rooms }` — the base shape used by the default upgrade handler.
|
|
769
|
+
|
|
770
|
+
### Overriding the message handler
|
|
771
|
+
|
|
772
|
+
Pass `ws.handler` to `createServer` to replace the default echo. Room action handling always runs first — your handler only receives non-room messages:
|
|
773
|
+
|
|
774
|
+
```ts
|
|
775
|
+
await createServer({
|
|
776
|
+
ws: {
|
|
777
|
+
handler: {
|
|
778
|
+
open(ws) {
|
|
779
|
+
ws.send(JSON.stringify({ event: "connected", id: ws.data.id }));
|
|
780
|
+
},
|
|
781
|
+
message(ws, message) {
|
|
782
|
+
// room subscribe/unsubscribe already handled — put your logic here
|
|
783
|
+
const parsed = JSON.parse(message as string);
|
|
784
|
+
if (parsed.action === "ping") ws.send(JSON.stringify({ event: "pong" }));
|
|
785
|
+
},
|
|
786
|
+
close(ws, code, reason) {
|
|
787
|
+
// ws.data.rooms already cleared
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
You can supply any subset of `open`, `message`, `close`, `drain` — unset handlers fall back to the defaults.
|
|
795
|
+
|
|
796
|
+
### Overriding the upgrade / auth handler
|
|
797
|
+
|
|
798
|
+
Replace the default cookie-JWT handshake entirely via `ws.upgradeHandler`. You must call `server.upgrade()` yourself and include `rooms: new Set()` in data:
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
await createServer({
|
|
802
|
+
ws: {
|
|
803
|
+
upgradeHandler: async (req, server) => {
|
|
804
|
+
const token = req.headers.get("x-my-token");
|
|
805
|
+
const userId = token ? await verifyMyToken(token) : null;
|
|
806
|
+
const upgraded = server.upgrade(req, {
|
|
807
|
+
data: { id: crypto.randomUUID(), userId, rooms: new Set() },
|
|
808
|
+
});
|
|
809
|
+
return upgraded ? undefined : Response.json({ error: "Upgrade failed" }, { status: 400 });
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
---
|
|
816
|
+
|
|
817
|
+
## WebSocket Rooms / Channels
|
|
818
|
+
|
|
819
|
+
Rooms are built on Bun's native pub/sub. `createServer` always intercepts room action messages first via `handleRoomActions` — so room subscribe/unsubscribe works regardless of whether you provide a custom `websocket.message`.
|
|
820
|
+
|
|
821
|
+
### WS utilities
|
|
822
|
+
|
|
823
|
+
| Export | Description |
|
|
824
|
+
|---|---|
|
|
825
|
+
| `publish(room, data)` | Broadcast `data` to all sockets subscribed to `room` |
|
|
826
|
+
| `subscribe(ws, room)` | Subscribe a socket to a room and track it in `ws.data.rooms` |
|
|
827
|
+
| `unsubscribe(ws, room)` | Unsubscribe a socket from a room |
|
|
828
|
+
| `getSubscriptions(ws)` | Returns `string[]` of rooms the socket is currently in |
|
|
829
|
+
| `getRooms()` | Returns `string[]` of all rooms with at least one active subscriber |
|
|
830
|
+
| `getRoomSubscribers(room)` | Returns `string[]` of socket IDs currently subscribed to `room` |
|
|
831
|
+
| `handleRoomActions(ws, message, onSubscribe?)` | Parses and dispatches subscribe/unsubscribe actions. Returns `true` if the message was a room action (consumed), `false` otherwise. Pass an optional async guard as the third argument. |
|
|
832
|
+
|
|
833
|
+
### Client → server: join or leave a room
|
|
834
|
+
|
|
835
|
+
Send a JSON message with `action: "subscribe"` or `action: "unsubscribe"`:
|
|
836
|
+
|
|
837
|
+
```ts
|
|
838
|
+
ws.send(JSON.stringify({ action: "subscribe", room: "chat:general" }));
|
|
839
|
+
ws.send(JSON.stringify({ action: "unsubscribe", room: "chat:general" }));
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
Server responses:
|
|
843
|
+
|
|
844
|
+
| Event | Meaning |
|
|
845
|
+
|---|---|
|
|
846
|
+
| `{ event: "subscribed", room }` | Successfully joined |
|
|
847
|
+
| `{ event: "unsubscribed", room }` | Successfully left |
|
|
848
|
+
| `{ event: "subscribe_denied", room }` | Blocked by `onRoomSubscribe` guard |
|
|
849
|
+
|
|
850
|
+
Any non-room message is passed through to your `websocket.message` handler unchanged.
|
|
851
|
+
|
|
852
|
+
### Server → room: broadcast
|
|
853
|
+
|
|
854
|
+
```ts
|
|
855
|
+
import { publish } from "@lastshotlabs/bunshot";
|
|
856
|
+
|
|
857
|
+
publish("chat:general", { text: "Hello room!", from: "system" });
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
All sockets subscribed to `"chat:general"` receive the message. Works from anywhere — routes, workers, anywhere after `createServer` resolves.
|
|
861
|
+
|
|
862
|
+
### Server-side: manage subscriptions in code
|
|
863
|
+
|
|
864
|
+
Use `subscribe` / `unsubscribe` anywhere you have a `ws` reference (e.g. in `ws.handler.open` to auto-join personal rooms):
|
|
865
|
+
|
|
866
|
+
```ts
|
|
867
|
+
import { subscribe, unsubscribe, getSubscriptions } from "@lastshotlabs/bunshot";
|
|
868
|
+
|
|
869
|
+
await createServer({
|
|
870
|
+
ws: {
|
|
871
|
+
handler: {
|
|
872
|
+
open(ws) {
|
|
873
|
+
// auto-subscribe authenticated users to their personal room
|
|
874
|
+
if (ws.data.userId) subscribe(ws, `user:${ws.data.userId}`);
|
|
875
|
+
},
|
|
876
|
+
message(ws, message) {
|
|
877
|
+
// handleRoomActions already ran — only non-room messages reach here
|
|
878
|
+
const rooms = getSubscriptions(ws); // current room list
|
|
879
|
+
},
|
|
880
|
+
close(ws) {
|
|
881
|
+
// ws.data.rooms is cleared automatically — no cleanup needed
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
### Room permission guard
|
|
889
|
+
|
|
890
|
+
Pass `ws.onRoomSubscribe` to `createServer` to gate which rooms a socket can join. Return `true` to allow, `false` to deny. Uses `ws.data.userId` for auth-based checks. Can be async.
|
|
891
|
+
|
|
892
|
+
```ts
|
|
893
|
+
await createServer({
|
|
894
|
+
ws: {
|
|
895
|
+
onRoomSubscribe(ws, room) {
|
|
896
|
+
if (!ws.data.userId) return false; // must be logged in
|
|
897
|
+
if (room.startsWith("admin:")) return isAdmin(ws.data.userId); // role check
|
|
898
|
+
if (room.startsWith("user:")) return room === `user:${ws.data.userId}`; // ownership
|
|
899
|
+
return true;
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// async guard — query DB or cache
|
|
905
|
+
await createServer({
|
|
906
|
+
ws: {
|
|
907
|
+
onRoomSubscribe: async (ws, room) => {
|
|
908
|
+
const ok = await db.roomMembers.findOne({ room, userId: ws.data.userId });
|
|
909
|
+
return !!ok;
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
---
|
|
916
|
+
|
|
917
|
+
## Adding Middleware
|
|
918
|
+
|
|
919
|
+
### Global (runs on every request)
|
|
920
|
+
|
|
921
|
+
Pass via `middleware` config — injected after `identify`, before route matching:
|
|
922
|
+
|
|
923
|
+
```ts
|
|
924
|
+
await createServer({
|
|
925
|
+
routesDir: import.meta.dir + "/routes",
|
|
926
|
+
app: { name: "My App", version: "1.0.0" },
|
|
927
|
+
middleware: [myMiddleware],
|
|
928
|
+
});
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
Write it using core's exported types:
|
|
932
|
+
|
|
933
|
+
```ts
|
|
934
|
+
// src/middleware/tenant.ts
|
|
935
|
+
import type { MiddlewareHandler } from "hono";
|
|
936
|
+
import type { AppEnv } from "@lastshotlabs/bunshot";
|
|
937
|
+
|
|
938
|
+
export const tenantMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|
939
|
+
// c.get("userId") is available — identify has already run
|
|
940
|
+
await next();
|
|
941
|
+
};
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
### Per-route
|
|
945
|
+
|
|
946
|
+
```ts
|
|
947
|
+
import { userAuth, rateLimit } from "@lastshotlabs/bunshot";
|
|
948
|
+
|
|
949
|
+
router.use("/admin", userAuth);
|
|
950
|
+
router.use("/admin", rateLimit({ windowMs: 60_000, max: 10 }));
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
---
|
|
954
|
+
|
|
955
|
+
## Response Caching
|
|
956
|
+
|
|
957
|
+
Cache GET responses and bust them from mutation endpoints. Supports Redis, MongoDB, SQLite, and memory stores. The cache key is automatically namespaced by `appName` (`cache:{appName}:{key}`), so shared instances across tenant apps never collide.
|
|
958
|
+
|
|
959
|
+
### Basic usage
|
|
960
|
+
|
|
961
|
+
```ts
|
|
962
|
+
import { cacheResponse, bustCache } from "@lastshotlabs/bunshot";
|
|
963
|
+
|
|
964
|
+
// GET — cache the response for 60 seconds in Redis (default)
|
|
965
|
+
router.use("/products", cacheResponse({ ttl: 60, key: "products" }));
|
|
966
|
+
|
|
967
|
+
// indefinite — cached until busted
|
|
968
|
+
router.use("/config", cacheResponse({ key: "config" }));
|
|
969
|
+
|
|
970
|
+
router.get("/products", async (c) => {
|
|
971
|
+
const items = await Product.find();
|
|
972
|
+
return c.json({ items });
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// POST — write data, then bust the shared key (hits all connected stores)
|
|
976
|
+
router.post("/products", userAuth, async (c) => {
|
|
977
|
+
const body = await c.req.json();
|
|
978
|
+
await Product.create(body);
|
|
979
|
+
await bustCache("products");
|
|
980
|
+
return c.json({ ok: true }, 201);
|
|
981
|
+
});
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
The `key` string is the shared contract — `cacheResponse` stores under it, `bustCache` deletes it. Responses include an `x-cache: HIT` or `x-cache: MISS` header.
|
|
985
|
+
|
|
986
|
+
### Choosing a cache store
|
|
987
|
+
|
|
988
|
+
Pass `store` to select where the response is cached. Defaults to `"redis"`.
|
|
989
|
+
|
|
990
|
+
```ts
|
|
991
|
+
// Redis (default)
|
|
992
|
+
cacheResponse({ key: "products", ttl: 60 })
|
|
993
|
+
|
|
994
|
+
// MongoDB — uses appConnection, stores in the `cache_entries` collection
|
|
995
|
+
// TTL is handled natively via a MongoDB expiry index on the expiresAt field
|
|
996
|
+
cacheResponse({ key: "products", ttl: 300, store: "mongo" })
|
|
997
|
+
|
|
998
|
+
// SQLite — uses the same .db file as sqliteAuthAdapter; requires setSqliteDb or sqliteDb config
|
|
999
|
+
cacheResponse({ key: "products", ttl: 60, store: "sqlite" })
|
|
1000
|
+
|
|
1001
|
+
// Memory — in-process Map, ephemeral (cleared on restart), no external dependencies
|
|
1002
|
+
cacheResponse({ key: "products", ttl: 60, store: "memory" })
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
Use SQLite when running without Redis or MongoDB. Use MongoDB when you want cache entries co-located with your app data. Use Redis for lower-latency hot caches. Use Memory for tests or single-process apps where persistence isn't needed.
|
|
1006
|
+
|
|
1007
|
+
**Connection requirements:** The chosen store must be initialized when the route is first hit. If `store: "sqlite"` is used but `setSqliteDb` has not been called (e.g. `sqliteDb` was not passed to `createServer`), the middleware throws a clear error on the first request. The same applies to the other stores.
|
|
1008
|
+
|
|
1009
|
+
### Busting cached entries
|
|
1010
|
+
|
|
1011
|
+
`bustCache` always attempts all four stores (Redis, Mongo, SQLite, Memory), skipping any that aren't connected. This means it works correctly regardless of which `store` option your routes use, and is safe to call in apps that don't use all stores:
|
|
1012
|
+
|
|
1013
|
+
```ts
|
|
1014
|
+
await bustCache("products"); // hits whichever stores are connected
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
### Per-user caching
|
|
1018
|
+
|
|
1019
|
+
The `key` function receives the full Hono context, so you can scope cache entries to the authenticated user:
|
|
1020
|
+
|
|
1021
|
+
```ts
|
|
1022
|
+
router.use("/feed", userAuth, cacheResponse({
|
|
1023
|
+
ttl: 60,
|
|
1024
|
+
key: (c) => `feed:${c.get("authUserId")}`,
|
|
1025
|
+
}));
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
`authUserId` is populated by `identify`, which always runs before route middleware, so it's safe to use here.
|
|
1029
|
+
|
|
1030
|
+
### Per-resource caching
|
|
1031
|
+
|
|
1032
|
+
For routes with dynamic segments, use the function form of `key`. Produce the same string in `bustCache`:
|
|
1033
|
+
|
|
1034
|
+
```ts
|
|
1035
|
+
// GET /products/:id
|
|
1036
|
+
router.use("/products/:id", cacheResponse({
|
|
1037
|
+
ttl: 60,
|
|
1038
|
+
key: (c) => `product:${c.req.param("id")}`,
|
|
1039
|
+
}));
|
|
1040
|
+
|
|
1041
|
+
router.get("/products/:id", async (c) => {
|
|
1042
|
+
const item = await Product.findById(c.req.param("id"));
|
|
1043
|
+
return c.json(item);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// PUT /products/:id
|
|
1047
|
+
router.put("/products/:id", userAuth, async (c) => {
|
|
1048
|
+
const id = c.req.param("id");
|
|
1049
|
+
await Product.findByIdAndUpdate(id, await c.req.json());
|
|
1050
|
+
await bustCache(`product:${id}`);
|
|
1051
|
+
return c.json({ ok: true });
|
|
1052
|
+
});
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
Only 2xx responses are cached. Non-2xx responses pass through uncached. Omit `ttl` to cache indefinitely — the entry will persist until explicitly busted with `bustCache`.
|
|
1056
|
+
|
|
1057
|
+
### Busting by pattern
|
|
1058
|
+
|
|
1059
|
+
When cache keys include variable parts (e.g. query params), use `bustCachePattern` to invalidate an entire logical group at once. It runs against all four stores — Redis (via SCAN), Mongo (via regex), SQLite (via LIKE), and Memory (via regex) — in parallel:
|
|
1060
|
+
|
|
1061
|
+
```ts
|
|
1062
|
+
import { bustCachePattern } from "@lastshotlabs/bunshot";
|
|
1063
|
+
|
|
1064
|
+
// key includes query params: `balance:${userId}:${from}:${to}:${groupBy}`
|
|
1065
|
+
// bust all balance entries for this user regardless of params
|
|
1066
|
+
await bustCachePattern(`balance:${userId}:*`);
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
The `*` wildcard is translated to a Redis glob, a Mongo/Memory regex, and a SQLite LIKE pattern automatically. Like `bustCache`, it silently skips any store that isn't connected, so it's safe to call in apps that only use one store.
|
|
1070
|
+
|
|
1071
|
+
---
|
|
1072
|
+
|
|
1073
|
+
## Extending the Context (Custom Variables)
|
|
1074
|
+
|
|
1075
|
+
When building a tenant app or any app that needs extra typed context variables (beyond the built-in), extend `AppEnv["Variables"]` and create a typed router factory.
|
|
1076
|
+
|
|
1077
|
+
```ts
|
|
1078
|
+
// src/lib/context.ts
|
|
1079
|
+
import { createRouter as coreCreateRouter, type AppEnv } from "@lastshotlabs/bunshot";
|
|
1080
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
1081
|
+
|
|
1082
|
+
export type MyVariables = AppEnv["Variables"] & {
|
|
1083
|
+
tenantId: string;
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
export type MyEnv = { Variables: MyVariables };
|
|
1087
|
+
|
|
1088
|
+
export const createRouter = () => coreCreateRouter() as unknown as OpenAPIHono<MyEnv>;
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
Use the local `createRouter` instead of the one from the package — your routes will then have full TypeScript access to the extra variables:
|
|
1092
|
+
|
|
1093
|
+
```ts
|
|
1094
|
+
// src/routes/items.ts
|
|
1095
|
+
import { createRouter } from "../lib/context";
|
|
1096
|
+
import { userAuth } from "@lastshotlabs/bunshot";
|
|
1097
|
+
|
|
1098
|
+
export const router = createRouter();
|
|
1099
|
+
|
|
1100
|
+
router.use("/items", userAuth);
|
|
1101
|
+
|
|
1102
|
+
router.get("/items", async (c) => {
|
|
1103
|
+
const tenantId = c.get("tenantId"); // fully typed
|
|
1104
|
+
const userId = c.get("userId"); // still available from AppEnv
|
|
1105
|
+
return c.json({ tenantId, userId });
|
|
1106
|
+
});
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
Populate the extra variables from a global middleware:
|
|
1110
|
+
|
|
1111
|
+
```ts
|
|
1112
|
+
// src/middleware/tenant.ts
|
|
1113
|
+
import type { MiddlewareHandler } from "hono";
|
|
1114
|
+
import type { MyEnv } from "../lib/context";
|
|
1115
|
+
|
|
1116
|
+
export const tenantMiddleware: MiddlewareHandler<MyEnv> = async (c, next) => {
|
|
1117
|
+
const tenantId = c.req.header("x-tenant-id") ?? "default";
|
|
1118
|
+
c.set("tenantId", tenantId);
|
|
1119
|
+
await next();
|
|
1120
|
+
};
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
Then register it in `createServer`:
|
|
1124
|
+
|
|
1125
|
+
```ts
|
|
1126
|
+
await createServer({
|
|
1127
|
+
routesDir: import.meta.dir + "/routes",
|
|
1128
|
+
app: { name: "My App", version: "1.0.0" },
|
|
1129
|
+
middleware: [tenantMiddleware],
|
|
1130
|
+
});
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
---
|
|
1134
|
+
|
|
1135
|
+
## Configuration
|
|
1136
|
+
|
|
1137
|
+
```ts
|
|
1138
|
+
await createServer({
|
|
1139
|
+
// Required
|
|
1140
|
+
routesDir: import.meta.dir + "/routes",
|
|
1141
|
+
|
|
1142
|
+
// Shared schemas (imported before routes; see "Shared schemas across routes" above)
|
|
1143
|
+
modelSchemas: import.meta.dir + "/schemas", // string shorthand — registration: "auto"
|
|
1144
|
+
// modelSchemas: [dir + "/schemas", dir + "/models"], // multiple dirs
|
|
1145
|
+
// modelSchemas: { paths: dir + "/schemas", registration: "explicit" }, // full object
|
|
1146
|
+
|
|
1147
|
+
// App metadata (shown in root endpoint + OpenAPI docs)
|
|
1148
|
+
app: {
|
|
1149
|
+
name: "My App", // default: "Bun Core API"
|
|
1150
|
+
version: "1.0.0", // default: "1.0.0"
|
|
1151
|
+
},
|
|
1152
|
+
|
|
1153
|
+
// Auth, roles, and OAuth
|
|
1154
|
+
auth: {
|
|
1155
|
+
enabled: true, // default: true — set false to disable /auth/* routes
|
|
1156
|
+
adapter: pgAuthAdapter, // custom adapter — overrides db.auth (use for Postgres etc.)
|
|
1157
|
+
roles: ["admin", "editor", "user"], // valid roles — required to use requireRole
|
|
1158
|
+
defaultRole: "user", // assigned to every new user on /auth/register
|
|
1159
|
+
primaryField: "email", // default: "email" — use "username" or "phone" to change the login identifier
|
|
1160
|
+
emailVerification: { // optional — only active when primaryField is "email"
|
|
1161
|
+
required: true, // default: false (soft gate) — set true to block login until verified
|
|
1162
|
+
tokenExpiry: 60 * 60, // default: 86400 (24 hours) — token TTL in seconds
|
|
1163
|
+
onSend: async (email, token) => { // called after registration and resend — use any email provider
|
|
1164
|
+
await resend.emails.send({ to: email, subject: "Verify your email", text: `Token: ${token}` });
|
|
1165
|
+
},
|
|
1166
|
+
},
|
|
1167
|
+
passwordReset: { // optional — only active when primaryField is "email"
|
|
1168
|
+
tokenExpiry: 60 * 60, // default: 3600 (1 hour) — token TTL in seconds
|
|
1169
|
+
onSend: async (email, token) => { // called by POST /auth/forgot-password — use any email provider
|
|
1170
|
+
await resend.emails.send({ to: email, subject: "Reset your password", text: `Token: ${token}` });
|
|
1171
|
+
},
|
|
1172
|
+
},
|
|
1173
|
+
rateLimit: { // optional — built-in auth endpoint rate limiting
|
|
1174
|
+
login: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 failures / 15 min
|
|
1175
|
+
register: { windowMs: 60 * 60 * 1000, max: 5 }, // default: 5 attempts / hour (per IP)
|
|
1176
|
+
verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
1177
|
+
resendVerification: { windowMs: 60 * 60 * 1000, max: 3 }, // default: 3 attempts / hour (per user)
|
|
1178
|
+
forgotPassword: { windowMs: 15 * 60 * 1000, max: 5 }, // default: 5 attempts / 15 min (per IP)
|
|
1179
|
+
resetPassword: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
1180
|
+
store: "redis", // default: "redis" when Redis is enabled, else "memory"
|
|
1181
|
+
},
|
|
1182
|
+
sessionPolicy: { // optional — session concurrency and metadata
|
|
1183
|
+
maxSessions: 6, // default: 6 — max simultaneous sessions per user; oldest evicted when exceeded
|
|
1184
|
+
persistSessionMetadata: true, // default: true — keep IP/UA/timestamp row after session expires (for device detection)
|
|
1185
|
+
includeInactiveSessions: false, // default: false — include expired/deleted sessions in GET /auth/sessions
|
|
1186
|
+
trackLastActive: false, // default: false — update lastActiveAt on every auth'd request (adds one DB write)
|
|
1187
|
+
},
|
|
1188
|
+
oauth: {
|
|
1189
|
+
providers: { google: { ... }, apple: { ... } }, // omit a provider to disable it
|
|
1190
|
+
postRedirect: "/dashboard", // default: "/"
|
|
1191
|
+
},
|
|
1192
|
+
refreshTokens: { // optional — short-lived access + long-lived refresh tokens
|
|
1193
|
+
accessTokenExpiry: 900, // default: 900 (15 min)
|
|
1194
|
+
refreshTokenExpiry: 2_592_000, // default: 2_592_000 (30 days)
|
|
1195
|
+
rotationGraceSeconds: 30, // default: 30 — old token still works briefly after rotation
|
|
1196
|
+
},
|
|
1197
|
+
mfa: { // optional — TOTP/MFA support (requires otpauth peer dep)
|
|
1198
|
+
issuer: "My App", // shown in authenticator apps (default: app name)
|
|
1199
|
+
recoveryCodes: 10, // default: 10
|
|
1200
|
+
challengeTtlSeconds: 300, // default: 300 (5 min)
|
|
1201
|
+
emailOtp: { // optional — email OTP as alternative MFA method
|
|
1202
|
+
onSend: async (email, code) => {}, // called to deliver the OTP code — use any email provider
|
|
1203
|
+
codeLength: 6, // default: 6
|
|
1204
|
+
},
|
|
1205
|
+
},
|
|
1206
|
+
accountDeletion: { // optional — enables DELETE /auth/me
|
|
1207
|
+
onBeforeDelete: async (userId) => {}, // throw to abort
|
|
1208
|
+
onAfterDelete: async (userId) => {}, // cleanup callback
|
|
1209
|
+
},
|
|
1210
|
+
},
|
|
1211
|
+
|
|
1212
|
+
// Multi-tenancy
|
|
1213
|
+
tenancy: {
|
|
1214
|
+
resolution: "header", // "header" | "subdomain" | "path"
|
|
1215
|
+
headerName: "x-tenant-id", // header name (when resolution is "header")
|
|
1216
|
+
onResolve: async (tenantId) => ({}), // validate/load tenant — return null to reject
|
|
1217
|
+
cacheTtlMs: 60_000, // LRU cache TTL (default: 60s, 0 to disable)
|
|
1218
|
+
cacheMaxSize: 500, // max cached entries (default: 500)
|
|
1219
|
+
exemptPaths: [], // extra paths that skip tenant resolution
|
|
1220
|
+
rejectionStatus: 403, // 403 (default) or 404
|
|
1221
|
+
},
|
|
1222
|
+
|
|
1223
|
+
// Job status endpoint
|
|
1224
|
+
jobs: {
|
|
1225
|
+
statusEndpoint: true, // default: false
|
|
1226
|
+
auth: "userAuth", // "userAuth" | "none" | MiddlewareHandler[]
|
|
1227
|
+
roles: ["admin"], // require roles (works with userAuth)
|
|
1228
|
+
allowedQueues: ["export"], // whitelist — empty = nothing exposed
|
|
1229
|
+
scopeToUser: false, // when true with userAuth, users see only their own jobs
|
|
1230
|
+
},
|
|
1231
|
+
|
|
1232
|
+
// Security
|
|
1233
|
+
security: {
|
|
1234
|
+
cors: ["https://myapp.com"], // default: "*"
|
|
1235
|
+
rateLimit: { windowMs: 60_000, max: 100 }, // default: 100 req/min
|
|
1236
|
+
bearerAuth: true, // default: true — set false to disable, or { bypass: ["/my-public-route"] }
|
|
1237
|
+
botProtection: {
|
|
1238
|
+
fingerprintRateLimit: true, // rate-limit by HTTP fingerprint (IP-rotation resistant). default: false
|
|
1239
|
+
blockList: ["198.51.100.0/24"], // IPv4 CIDRs or exact IPs to block with 403. default: []
|
|
1240
|
+
},
|
|
1241
|
+
},
|
|
1242
|
+
|
|
1243
|
+
// Extra middleware injected after identify, before route matching
|
|
1244
|
+
middleware: [],
|
|
1245
|
+
|
|
1246
|
+
// Connections & store routing (all optional — shown with defaults)
|
|
1247
|
+
db: {
|
|
1248
|
+
mongo: "single", // "single" | "separate" | false
|
|
1249
|
+
redis: true, // false to skip auto-connect
|
|
1250
|
+
sqlite: undefined, // absolute path to .db file — required when any store is "sqlite"
|
|
1251
|
+
auth: "mongo", // "mongo" | "sqlite" | "memory" — which built-in auth adapter to use
|
|
1252
|
+
sessions: "redis", // "redis" | "mongo" | "sqlite" | "memory"
|
|
1253
|
+
oauthState: "redis", // default: follows sessions
|
|
1254
|
+
cache: "redis", // global default for cacheResponse (overridable per-route)
|
|
1255
|
+
},
|
|
1256
|
+
|
|
1257
|
+
// Server
|
|
1258
|
+
port: 3000, // default: process.env.PORT ?? 3000
|
|
1259
|
+
workersDir: import.meta.dir + "/workers", // auto-imports all .ts files after server starts
|
|
1260
|
+
enableWorkers: true, // default: true — set false to disable auto-loading
|
|
1261
|
+
|
|
1262
|
+
// WebSocket (see WebSocket section for full examples)
|
|
1263
|
+
ws: {
|
|
1264
|
+
handler: { ... }, // override open/message/close/drain handlers
|
|
1265
|
+
upgradeHandler: async (req, server) => { ... }, // replace default cookie-JWT upgrade logic
|
|
1266
|
+
onRoomSubscribe(ws, room) { return true; }, // gate room subscriptions; can be async
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
---
|
|
1272
|
+
|
|
1273
|
+
## Running without Redis
|
|
1274
|
+
|
|
1275
|
+
Set `db.redis: false` and `db.sessions: "mongo"` to run the entire auth flow on MongoDB only. Sessions, OAuth state, and response caching (when `store: "mongo"`) all work without Redis. The only feature that still requires Redis is BullMQ queues.
|
|
1276
|
+
|
|
1277
|
+
```ts
|
|
1278
|
+
await createServer({
|
|
1279
|
+
db: {
|
|
1280
|
+
mongo: "single",
|
|
1281
|
+
redis: false,
|
|
1282
|
+
sessions: "mongo", // sessions + OAuth state → MongoDB
|
|
1283
|
+
cache: "mongo", // or omit cacheResponse entirely if not using it
|
|
1284
|
+
},
|
|
1285
|
+
});
|
|
1286
|
+
```
|
|
1287
|
+
|
|
1288
|
+
Redis key namespacing: when Redis is used, all keys are prefixed with `appName` (`session:{appName}:{sessionId}`, `usersessions:{appName}:{userId}`, `oauth:{appName}:state:{state}`, `cache:{appName}:{key}`) so multiple apps sharing one Redis instance never collide.
|
|
1289
|
+
|
|
1290
|
+
---
|
|
1291
|
+
|
|
1292
|
+
## Running without Redis or MongoDB
|
|
1293
|
+
|
|
1294
|
+
Two lightweight options for local dev, tests, or small projects with no external services:
|
|
1295
|
+
|
|
1296
|
+
### SQLite — persisted to disk
|
|
1297
|
+
|
|
1298
|
+
Uses `bun:sqlite` (built into Bun, zero npm deps). A single `.db` file holds all users, sessions, OAuth state, and cache.
|
|
1299
|
+
|
|
1300
|
+
```ts
|
|
1301
|
+
await createServer({
|
|
1302
|
+
routesDir: import.meta.dir + "/routes",
|
|
1303
|
+
app: { name: "My App", version: "1.0.0" },
|
|
1304
|
+
db: {
|
|
1305
|
+
auth: "sqlite",
|
|
1306
|
+
sqlite: import.meta.dir + "/../data.db", // created automatically on first run
|
|
1307
|
+
mongo: false,
|
|
1308
|
+
redis: false,
|
|
1309
|
+
sessions: "sqlite",
|
|
1310
|
+
cache: "sqlite",
|
|
1311
|
+
},
|
|
1312
|
+
});
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
#### Optional: periodic cleanup of expired rows
|
|
1316
|
+
|
|
1317
|
+
Expired rows are filtered out lazily on read. For long-running servers, sweep them periodically:
|
|
1318
|
+
|
|
1319
|
+
```ts
|
|
1320
|
+
import { startSqliteCleanup } from "@lastshotlabs/bunshot";
|
|
1321
|
+
|
|
1322
|
+
startSqliteCleanup(); // default: every hour
|
|
1323
|
+
startSqliteCleanup(5 * 60_000); // custom interval (ms)
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
### Memory — ephemeral, great for tests
|
|
1327
|
+
|
|
1328
|
+
Pure in-memory Maps. No files, no external services. All state is lost on process restart.
|
|
1329
|
+
|
|
1330
|
+
```ts
|
|
1331
|
+
import { createServer, clearMemoryStore } from "@lastshotlabs/bunshot";
|
|
1332
|
+
|
|
1333
|
+
await createServer({
|
|
1334
|
+
routesDir: import.meta.dir + "/routes",
|
|
1335
|
+
app: { name: "My App", version: "1.0.0" },
|
|
1336
|
+
db: {
|
|
1337
|
+
auth: "memory",
|
|
1338
|
+
mongo: false,
|
|
1339
|
+
redis: false,
|
|
1340
|
+
sessions: "memory",
|
|
1341
|
+
cache: "memory",
|
|
1342
|
+
},
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
// In tests — reset all state between test cases:
|
|
1346
|
+
clearMemoryStore();
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
### Limitations (both sqlite and memory)
|
|
1350
|
+
|
|
1351
|
+
- BullMQ queues still require Redis
|
|
1352
|
+
|
|
1353
|
+
---
|
|
1354
|
+
|
|
1355
|
+
## Auth Flow
|
|
1356
|
+
|
|
1357
|
+
Sessions are backed by Redis by default. Each login creates an independent session keyed by a UUID (`session:{appName}:{sessionId}`), so multiple devices / tabs can be logged in simultaneously. Set `db.sessions: "mongo"` to store them in MongoDB instead — useful when running without Redis. See [Running without Redis](#running-without-redis).
|
|
1358
|
+
|
|
1359
|
+
### Browser clients
|
|
1360
|
+
1. `POST /auth/login` → JWT set as HttpOnly cookie automatically
|
|
1361
|
+
2. All subsequent requests send the cookie — no extra code needed
|
|
1362
|
+
|
|
1363
|
+
### API / non-browser clients
|
|
1364
|
+
1. `POST /auth/login` → read `token` from response body
|
|
1365
|
+
2. Send `x-user-token: <token>` header on every request
|
|
1366
|
+
|
|
1367
|
+
### Session management
|
|
1368
|
+
|
|
1369
|
+
Each login creates an independent session so multiple devices stay logged in simultaneously. The framework enforces a configurable cap (default: 6) — the oldest session is evicted when the limit is exceeded.
|
|
1370
|
+
|
|
1371
|
+
```
|
|
1372
|
+
GET /auth/sessions → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
|
|
1373
|
+
DELETE /auth/sessions/:sessionId → revoke a specific session (other sessions unaffected)
|
|
1374
|
+
POST /auth/logout → revoke only the current session
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
Session metadata (IP address, user-agent, timestamps) is persisted even after a session expires when `sessionPolicy.persistSessionMetadata: true` (default). This enables tenant apps to detect logins from novel devices or locations and prompt for MFA or send a security alert.
|
|
1378
|
+
|
|
1379
|
+
Set `sessionPolicy.includeInactiveSessions: true` to surface expired/deleted sessions in `GET /auth/sessions` with `isActive: false` — useful for a full device-history UI similar to Google or Meta's account security page.
|
|
1380
|
+
|
|
1381
|
+
#### Sliding sessions
|
|
1382
|
+
|
|
1383
|
+
Set `sessionPolicy.trackLastActive: true` to update `lastActiveAt` on every authenticated request. This adds one DB write per request but enables a sliding-session experience — sessions that are actively used stay fresh. Pair with refresh tokens (below) for true sliding behavior: short-lived access tokens (15 min) keep authorization tight, while a long-lived refresh token (30 days) lets the client silently renew without re-entering credentials.
|
|
1384
|
+
|
|
1385
|
+
### Refresh Tokens
|
|
1386
|
+
|
|
1387
|
+
When configured, login and register return short-lived access tokens (default 15 min) alongside long-lived refresh tokens (default 30 days). The client uses `POST /auth/refresh` to obtain a new access token when the current one expires.
|
|
1388
|
+
|
|
1389
|
+
```ts
|
|
1390
|
+
await createServer({
|
|
1391
|
+
auth: {
|
|
1392
|
+
refreshTokens: {
|
|
1393
|
+
accessTokenExpiry: 900, // seconds, default: 900 (15 min)
|
|
1394
|
+
refreshTokenExpiry: 2_592_000, // seconds, default: 2_592_000 (30 days)
|
|
1395
|
+
rotationGraceSeconds: 30, // default: 30 — old token still works briefly after rotation
|
|
1396
|
+
},
|
|
1397
|
+
},
|
|
1398
|
+
});
|
|
1399
|
+
```
|
|
1400
|
+
|
|
1401
|
+
**When not configured**, the existing 7-day JWT behavior is unchanged — fully backward compatible.
|
|
1402
|
+
|
|
1403
|
+
#### Endpoints
|
|
1404
|
+
|
|
1405
|
+
| Endpoint | Purpose |
|
|
1406
|
+
|---|---|
|
|
1407
|
+
| `POST /auth/login` | Returns `token` + `refreshToken` |
|
|
1408
|
+
| `POST /auth/register` | Returns `token` + `refreshToken` |
|
|
1409
|
+
| `POST /auth/refresh` | Rotates refresh token, returns new `token` + `refreshToken` |
|
|
1410
|
+
|
|
1411
|
+
#### Rotation with grace window
|
|
1412
|
+
|
|
1413
|
+
On each refresh, the server generates a new refresh token but keeps the old one valid for `rotationGraceSeconds` (default 30s). If the client's network drops mid-refresh, it can safely retry with the old token. If the old token is reused *after* the grace window, the entire session is invalidated — this is token-family theft detection.
|
|
1414
|
+
|
|
1415
|
+
#### Cookie behavior
|
|
1416
|
+
|
|
1417
|
+
The refresh token is set as an `HttpOnly` cookie (`refresh_token`) alongside the existing session cookie. For non-browser clients, it's also returned in the JSON body and accepted via the `x-refresh-token` header.
|
|
1418
|
+
|
|
1419
|
+
### MFA / TOTP
|
|
1420
|
+
|
|
1421
|
+
Enable multi-factor authentication with TOTP (Google Authenticator, Authy, etc.):
|
|
1422
|
+
|
|
1423
|
+
```ts
|
|
1424
|
+
await createServer({
|
|
1425
|
+
auth: {
|
|
1426
|
+
mfa: {
|
|
1427
|
+
issuer: "My App", // shown in authenticator apps (default: app name)
|
|
1428
|
+
algorithm: "SHA1", // default, most compatible
|
|
1429
|
+
digits: 6, // default
|
|
1430
|
+
period: 30, // seconds, default
|
|
1431
|
+
recoveryCodes: 10, // number of recovery codes, default: 10
|
|
1432
|
+
challengeTtlSeconds: 300, // MFA challenge window, default: 5 min
|
|
1433
|
+
},
|
|
1434
|
+
},
|
|
1435
|
+
});
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
Requires `otpauth` peer dependency:
|
|
1439
|
+
|
|
1440
|
+
```bash
|
|
1441
|
+
bun add otpauth
|
|
1442
|
+
```
|
|
1443
|
+
|
|
1444
|
+
#### Endpoints
|
|
1445
|
+
|
|
1446
|
+
| Endpoint | Auth | Purpose |
|
|
1447
|
+
|---|---|---|
|
|
1448
|
+
| `POST /auth/mfa/setup` | userAuth | Generate TOTP secret + otpauth URI (for QR code) |
|
|
1449
|
+
| `POST /auth/mfa/verify-setup` | userAuth | Confirm with TOTP code, returns recovery codes |
|
|
1450
|
+
| `POST /auth/mfa/verify` | none (uses mfaToken) | Complete login after password verified |
|
|
1451
|
+
| `DELETE /auth/mfa` | userAuth | Disable all MFA (requires TOTP code) |
|
|
1452
|
+
| `POST /auth/mfa/recovery-codes` | userAuth | Regenerate codes (requires TOTP code) |
|
|
1453
|
+
| `GET /auth/mfa/methods` | userAuth | Get enabled MFA methods |
|
|
1454
|
+
|
|
1455
|
+
#### Login flow with MFA enabled
|
|
1456
|
+
|
|
1457
|
+
1. `POST /auth/login` with credentials → password OK + MFA enabled → `{ mfaRequired: true, mfaToken: "...", mfaMethods: ["totp"] }` (no session created)
|
|
1458
|
+
2. `POST /auth/mfa/verify` with `{ mfaToken, code }` → verifies TOTP or recovery code → creates session → returns normal token response
|
|
1459
|
+
|
|
1460
|
+
The verify endpoint accepts an optional `method` field (`"totp"` or `"emailOtp"`) to target a specific verification method. When omitted, methods are tried automatically.
|
|
1461
|
+
|
|
1462
|
+
**OAuth logins skip MFA** — the OAuth provider is treated as the second factor.
|
|
1463
|
+
|
|
1464
|
+
**Recovery codes**: 10 random 8-character alphanumeric codes, stored as SHA-256 hashes. Each code can only be used once. Enabling a second MFA method regenerates recovery codes — save the new set.
|
|
1465
|
+
|
|
1466
|
+
### Email OTP
|
|
1467
|
+
|
|
1468
|
+
An alternative to TOTP that sends a one-time code to the user's email. Users can enable TOTP, email OTP, or both.
|
|
1469
|
+
|
|
1470
|
+
```ts
|
|
1471
|
+
await createServer({
|
|
1472
|
+
auth: {
|
|
1473
|
+
mfa: {
|
|
1474
|
+
challengeTtlSeconds: 300,
|
|
1475
|
+
emailOtp: {
|
|
1476
|
+
onSend: async (email, code) => {
|
|
1477
|
+
await sendEmail(email, `Your login code: ${code}`);
|
|
1478
|
+
},
|
|
1479
|
+
codeLength: 6, // default
|
|
1480
|
+
},
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
});
|
|
1484
|
+
```
|
|
1485
|
+
|
|
1486
|
+
#### Endpoints
|
|
1487
|
+
|
|
1488
|
+
| Endpoint | Auth | Purpose |
|
|
1489
|
+
|---|---|---|
|
|
1490
|
+
| `POST /auth/mfa/email-otp/enable` | userAuth | Send verification code to email |
|
|
1491
|
+
| `POST /auth/mfa/email-otp/verify-setup` | userAuth | Confirm code, enable email OTP |
|
|
1492
|
+
| `DELETE /auth/mfa/email-otp` | userAuth | Disable email OTP |
|
|
1493
|
+
| `POST /auth/mfa/resend` | none (uses mfaToken) | Resend email OTP code (max 3 per challenge) |
|
|
1494
|
+
|
|
1495
|
+
#### Setup flow
|
|
1496
|
+
|
|
1497
|
+
1. `POST /auth/mfa/email-otp/enable` → sends code to email → returns `{ setupToken }`
|
|
1498
|
+
2. `POST /auth/mfa/email-otp/verify-setup` with `{ setupToken, code }` → enables email OTP → returns recovery codes
|
|
1499
|
+
|
|
1500
|
+
This two-step flow ensures the `onSend` callback actually delivers emails before MFA is activated, preventing lockout from misconfigured email providers.
|
|
1501
|
+
|
|
1502
|
+
#### Login flow with email OTP
|
|
1503
|
+
|
|
1504
|
+
1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["emailOtp"] }` — code is auto-sent to user's email
|
|
1505
|
+
2. `POST /auth/mfa/verify` with `{ mfaToken, code }` → creates session
|
|
1506
|
+
3. If the code didn't arrive: `POST /auth/mfa/resend` with `{ mfaToken }` (max 3 resends, capped at 3x challenge TTL)
|
|
1507
|
+
|
|
1508
|
+
#### Disabling email OTP
|
|
1509
|
+
|
|
1510
|
+
- If TOTP is also enabled: requires a TOTP code in the `code` field
|
|
1511
|
+
- If email OTP is the only method: requires the account password in the `password` field
|
|
1512
|
+
- Disabling the last MFA method turns off MFA entirely
|
|
1513
|
+
|
|
1514
|
+
### Account Deletion
|
|
1515
|
+
|
|
1516
|
+
Enable `DELETE /auth/me` for user-initiated account deletion:
|
|
1517
|
+
|
|
1518
|
+
```ts
|
|
1519
|
+
await createServer({
|
|
1520
|
+
auth: {
|
|
1521
|
+
accountDeletion: {
|
|
1522
|
+
onBeforeDelete: async (userId) => {
|
|
1523
|
+
// Throw to abort (e.g., check for active subscription)
|
|
1524
|
+
},
|
|
1525
|
+
onAfterDelete: async (userId) => {
|
|
1526
|
+
// Cleanup: delete S3 files, cancel Stripe, etc.
|
|
1527
|
+
// Runs at execution time — query current state, not a snapshot
|
|
1528
|
+
},
|
|
1529
|
+
queued: false, // set true for async deletion via BullMQ
|
|
1530
|
+
gracePeriod: 0, // seconds before queued deletion executes
|
|
1531
|
+
onDeletionScheduled: async (userId, email, cancelToken) => {
|
|
1532
|
+
// Send cancellation email with cancelToken link
|
|
1533
|
+
},
|
|
1534
|
+
},
|
|
1535
|
+
},
|
|
1536
|
+
});
|
|
1537
|
+
```
|
|
1538
|
+
|
|
1539
|
+
#### Behavior
|
|
1540
|
+
|
|
1541
|
+
- Requires `userAuth` middleware (user must be logged in)
|
|
1542
|
+
- Body: `{ password?: string }` — required for credential accounts, skipped for OAuth-only
|
|
1543
|
+
- Revokes all sessions, deletes tokens, calls `adapter.deleteUser(userId)`
|
|
1544
|
+
- Rate limited (3/hour by userId)
|
|
1545
|
+
|
|
1546
|
+
#### Queued deletion
|
|
1547
|
+
|
|
1548
|
+
When `queued: true`, deletion is enqueued as a BullMQ job instead of running synchronously. The endpoint returns `202 Accepted` immediately. With `gracePeriod > 0`, the user can cancel via `POST /auth/cancel-deletion`.
|
|
1549
|
+
|
|
1550
|
+
### Protecting routes
|
|
1551
|
+
|
|
1552
|
+
```ts
|
|
1553
|
+
import { userAuth, requireRole, requireVerifiedEmail } from "@lastshotlabs/bunshot";
|
|
1554
|
+
|
|
1555
|
+
router.use("/my-route", userAuth); // returns 401 if not logged in
|
|
1556
|
+
router.use("/admin", userAuth, requireRole("admin")); // returns 403 if user lacks role
|
|
1557
|
+
router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
|
|
1558
|
+
router.use("/dashboard", userAuth, requireVerifiedEmail); // returns 403 if email not verified
|
|
1559
|
+
```
|
|
1560
|
+
|
|
1561
|
+
### Custom auth adapter
|
|
1562
|
+
|
|
1563
|
+
By default, `/auth/*` routes store users in MongoDB via `mongoAuthAdapter`. Pass `auth: { adapter: myAdapter }` to `createServer` to use any other store — Postgres, SQLite, an external service, etc. Alternatively, use `db.auth` to select a built-in adapter (`"mongo"` | `"sqlite"` | `"memory"`).
|
|
1564
|
+
|
|
1565
|
+
The schema should include a `roles` column if you plan to use role-based access:
|
|
1566
|
+
|
|
1567
|
+
```sql
|
|
1568
|
+
-- roles stored as a text array in Postgres
|
|
1569
|
+
ALTER TABLE users ADD COLUMN roles text[] NOT NULL DEFAULT '{}';
|
|
1570
|
+
```
|
|
1571
|
+
|
|
1572
|
+
```ts
|
|
1573
|
+
import type { AuthAdapter } from "@lastshotlabs/bunshot";
|
|
1574
|
+
import { HttpError } from "@lastshotlabs/bunshot";
|
|
1575
|
+
import { db } from "./db";
|
|
1576
|
+
import { users } from "./schema";
|
|
1577
|
+
import { eq, sql } from "drizzle-orm";
|
|
1578
|
+
|
|
1579
|
+
const pgAuthAdapter: AuthAdapter = {
|
|
1580
|
+
async findByEmail(email) {
|
|
1581
|
+
const user = await db.query.users.findFirst({ where: eq(users.email, email) });
|
|
1582
|
+
return user ? { id: user.id, passwordHash: user.passwordHash } : null;
|
|
1583
|
+
},
|
|
1584
|
+
async create(email, passwordHash) {
|
|
1585
|
+
try {
|
|
1586
|
+
const [user] = await db.insert(users).values({ email, passwordHash }).returning({ id: users.id });
|
|
1587
|
+
return { id: user.id };
|
|
1588
|
+
} catch (err: any) {
|
|
1589
|
+
if (/* unique constraint */ err.code === "23505") throw new HttpError(409, "Email already registered");
|
|
1590
|
+
throw err;
|
|
1591
|
+
}
|
|
1592
|
+
},
|
|
1593
|
+
// --- Role methods (optional — only needed if using roles / requireRole) ---
|
|
1594
|
+
async getRoles(userId) {
|
|
1595
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
1596
|
+
return user?.roles ?? [];
|
|
1597
|
+
},
|
|
1598
|
+
async setRoles(userId, roles) { // required if using defaultRole
|
|
1599
|
+
await db.update(users).set({ roles }).where(eq(users.id, userId));
|
|
1600
|
+
},
|
|
1601
|
+
async addRole(userId, role) {
|
|
1602
|
+
await db.update(users)
|
|
1603
|
+
.set({ roles: sql`array_append(roles, ${role})` })
|
|
1604
|
+
.where(eq(users.id, userId));
|
|
1605
|
+
},
|
|
1606
|
+
async removeRole(userId, role) {
|
|
1607
|
+
await db.update(users)
|
|
1608
|
+
.set({ roles: sql`array_remove(roles, ${role})` })
|
|
1609
|
+
.where(eq(users.id, userId));
|
|
1610
|
+
},
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
await createServer({
|
|
1614
|
+
routesDir: import.meta.dir + "/routes",
|
|
1615
|
+
app: { name: "My App", version: "1.0.0" },
|
|
1616
|
+
auth: {
|
|
1617
|
+
roles: ["admin", "editor", "user"],
|
|
1618
|
+
defaultRole: "user",
|
|
1619
|
+
adapter: pgAuthAdapter,
|
|
1620
|
+
},
|
|
1621
|
+
});
|
|
1622
|
+
```
|
|
1623
|
+
|
|
1624
|
+
The adapter is responsible for:
|
|
1625
|
+
- `findByEmail` — return `{ id, passwordHash }` or `null` if not found
|
|
1626
|
+
- `create` — insert the user and return `{ id }`, throw `HttpError(409, ...)` on duplicate email
|
|
1627
|
+
- `setPassword` _(optional)_ — update the stored password hash for `userId`; implement to enable `POST /auth/set-password`
|
|
1628
|
+
- `findOrCreateByProvider` _(optional)_ — required for OAuth social login
|
|
1629
|
+
- `linkProvider` _(optional)_ — add a provider identity to an existing user; implement to enable `GET /auth/{provider}/link`
|
|
1630
|
+
- `unlinkProvider` _(optional)_ — remove all identities for a provider from a user; implement to enable `DELETE /auth/{provider}/link`
|
|
1631
|
+
- `getRoles` _(optional)_ — return the roles assigned to `userId`; implement to enable `requireRole` middleware
|
|
1632
|
+
- `setRoles` _(optional)_ — replace all roles; required if using `defaultRole`
|
|
1633
|
+
- `addRole` _(optional)_ — add a single role; implement to use `addUserRole`
|
|
1634
|
+
- `removeRole` _(optional)_ — remove a single role; implement to use `removeUserRole`
|
|
1635
|
+
- `getUser` _(optional)_ — return `{ email?, providerIds?, emailVerified? }` for `userId`; implement to populate `GET /auth/me` (including `googleLinked` and `emailVerified`)
|
|
1636
|
+
- `findByIdentifier` _(optional)_ — look up a user by the configured `primaryField` value; implement for non-email primary fields. Falls back to `findByEmail` if absent.
|
|
1637
|
+
- `setEmailVerified` _(optional)_ — mark a user as email-verified; implement to support `POST /auth/verify-email`
|
|
1638
|
+
- `getEmailVerified` _(optional)_ — return whether a user is email-verified; implement to support the `emailVerification.required` gate and `POST /auth/resend-verification`
|
|
1639
|
+
|
|
1640
|
+
Everything else (password hashing, JWT signing, Redis sessions) is handled by the package.
|
|
1641
|
+
|
|
1642
|
+
### Auth Rate Limiting
|
|
1643
|
+
|
|
1644
|
+
All built-in auth endpoints are rate-limited out of the box with sensible defaults. No configuration needed — just be aware of the behavior:
|
|
1645
|
+
|
|
1646
|
+
| Endpoint | Key | Counts | Default limit |
|
|
1647
|
+
|---|---|---|---|
|
|
1648
|
+
| `POST /auth/login` | identifier (email/username/phone) | **Failures only** — reset on success | 10 failures / 15 min |
|
|
1649
|
+
| `POST /auth/register` | IP address | Every attempt | 5 / hour |
|
|
1650
|
+
| `POST /auth/verify-email` | IP address | Every attempt | 10 / 15 min |
|
|
1651
|
+
| `POST /auth/resend-verification` | Identifier (email/username/phone) | Every attempt | 3 / hour |
|
|
1652
|
+
| `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
|
|
1653
|
+
| `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
|
|
1654
|
+
|
|
1655
|
+
Login is keyed by the **identifier being targeted** — an attacker rotating IPs to brute-force `alice@example.com` is blocked regardless of source IP. A successful login resets the counter so legitimate users aren't locked out.
|
|
1656
|
+
|
|
1657
|
+
#### Tuning limits
|
|
1658
|
+
|
|
1659
|
+
```ts
|
|
1660
|
+
await createServer({
|
|
1661
|
+
auth: {
|
|
1662
|
+
rateLimit: {
|
|
1663
|
+
login: { windowMs: 10 * 60 * 1000, max: 5 }, // stricter: 5 failures / 10 min
|
|
1664
|
+
register: { windowMs: 60 * 60 * 1000, max: 3 },
|
|
1665
|
+
verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // leave at default
|
|
1666
|
+
resendVerification: { windowMs: 60 * 60 * 1000, max: 2 },
|
|
1667
|
+
store: "redis", // default when Redis is enabled — shared across all server instances
|
|
1668
|
+
},
|
|
1669
|
+
},
|
|
1670
|
+
});
|
|
1671
|
+
```
|
|
1672
|
+
|
|
1673
|
+
#### Manually clearing a limit (admin unlock)
|
|
1674
|
+
|
|
1675
|
+
If a legitimate user gets locked out, call `bustAuthLimit` with the same key format the limiter uses:
|
|
1676
|
+
|
|
1677
|
+
```ts
|
|
1678
|
+
import { bustAuthLimit } from "@lastshotlabs/bunshot";
|
|
1679
|
+
|
|
1680
|
+
// Admin route: POST /admin/unblock-login
|
|
1681
|
+
router.post("/admin/unblock-login", userAuth, requireRole("admin"), async (c) => {
|
|
1682
|
+
const { identifier } = await c.req.json();
|
|
1683
|
+
await bustAuthLimit(`login:${identifier}`);
|
|
1684
|
+
return c.json({ message: "Login limit cleared" });
|
|
1685
|
+
});
|
|
1686
|
+
```
|
|
1687
|
+
|
|
1688
|
+
Key formats: `login:{identifier}`, `register:{ip}`, `verify:{ip}`, `resend:{userId}`.
|
|
1689
|
+
|
|
1690
|
+
#### Using the rate limiter in your own routes
|
|
1691
|
+
|
|
1692
|
+
`trackAttempt` and `isLimited` are exported so you can apply the same Redis-backed rate limiting to any route in your app. They use the same store configured via `auth.rateLimit.store`.
|
|
1693
|
+
|
|
1694
|
+
```ts
|
|
1695
|
+
import { trackAttempt, isLimited, bustAuthLimit } from "@lastshotlabs/bunshot";
|
|
1696
|
+
|
|
1697
|
+
// trackAttempt — increments the counter and returns true if now over the limit
|
|
1698
|
+
// isLimited — checks without incrementing (read-only)
|
|
1699
|
+
// bustAuthLimit — resets a key (e.g. on success or admin unlock)
|
|
1700
|
+
|
|
1701
|
+
router.post("/api/submit", async (c) => {
|
|
1702
|
+
const ip = c.req.header("x-forwarded-for") ?? "unknown";
|
|
1703
|
+
const key = `submit:${ip}`;
|
|
1704
|
+
|
|
1705
|
+
if (await trackAttempt(key, { windowMs: 60 * 1000, max: 5 })) {
|
|
1706
|
+
return c.json({ error: "Too many requests" }, 429);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// ... handle request
|
|
1710
|
+
return c.json({ ok: true });
|
|
1711
|
+
});
|
|
1712
|
+
```
|
|
1713
|
+
|
|
1714
|
+
Use `isLimited` when you want to check the current state without counting the request itself — for example, to gate an expensive pre-check before the attempt is registered:
|
|
1715
|
+
|
|
1716
|
+
```ts
|
|
1717
|
+
if (await isLimited(key, opts)) {
|
|
1718
|
+
return c.json({ error: "Too many requests" }, 429);
|
|
1719
|
+
}
|
|
1720
|
+
```
|
|
1721
|
+
|
|
1722
|
+
Keys are automatically namespaced to the app (e.g. `rl:MyApp:submit:1.2.3.4`) when the Redis store is active, so they won't collide on a shared Redis instance.
|
|
1723
|
+
|
|
1724
|
+
#### Store
|
|
1725
|
+
|
|
1726
|
+
The rate limit store defaults to `"redis"` when Redis is enabled (recommended for multi-instance deployments — limits are shared across all servers). Falls back to `"memory"` automatically when Redis is disabled. In-memory limits don't persist across restarts.
|
|
1727
|
+
|
|
1728
|
+
---
|
|
1729
|
+
|
|
1730
|
+
### Bot Protection
|
|
1731
|
+
|
|
1732
|
+
The built-in IP rate limiter is ineffective against bots that rotate IPs. The `botProtection` config adds two IP-rotation-resistant layers that run before the IP rate limit check.
|
|
1733
|
+
|
|
1734
|
+
#### Fingerprint rate limiting
|
|
1735
|
+
|
|
1736
|
+
When `fingerprintRateLimit: true`, every request is also rate-limited by an HTTP fingerprint — a 12-char hash derived from `User-Agent`, `Accept-*`, `Connection`, and the presence/absence of browser-only headers (`sec-fetch-*`, `sec-ch-ua-*`, `origin`, `referer`, etc.).
|
|
1737
|
+
|
|
1738
|
+
Bots that rotate IPs but use the same HTTP client (e.g. Python `requests`, `curl`, a headless browser) produce the same fingerprint and share a rate-limit bucket regardless of their source IP. Real browser sessions produce a different fingerprint from CLI tools, so they don't interfere with each other.
|
|
1739
|
+
|
|
1740
|
+
```ts
|
|
1741
|
+
await createServer({
|
|
1742
|
+
security: {
|
|
1743
|
+
rateLimit: { windowMs: 60_000, max: 100 }, // applies to both IP and fingerprint buckets
|
|
1744
|
+
botProtection: {
|
|
1745
|
+
fingerprintRateLimit: true,
|
|
1746
|
+
},
|
|
1747
|
+
},
|
|
1748
|
+
});
|
|
1749
|
+
```
|
|
1750
|
+
|
|
1751
|
+
The fingerprint bucket uses the same window and max as `security.rateLimit`, and is stored in the same backend as `auth.rateLimit.store` (Redis by default, shared across all instances).
|
|
1752
|
+
|
|
1753
|
+
#### IP / CIDR blocklist
|
|
1754
|
+
|
|
1755
|
+
Block known datacenter ranges, proxy providers, or individual IPs outright. Matched requests receive a 403 before any other processing — no session lookup, no rate-limit increment.
|
|
1756
|
+
|
|
1757
|
+
```ts
|
|
1758
|
+
await createServer({
|
|
1759
|
+
security: {
|
|
1760
|
+
botProtection: {
|
|
1761
|
+
blockList: [
|
|
1762
|
+
"198.51.100.0/24", // IPv4 CIDR
|
|
1763
|
+
"203.0.113.42", // exact IPv4
|
|
1764
|
+
"2001:db8::1", // exact IPv6
|
|
1765
|
+
],
|
|
1766
|
+
},
|
|
1767
|
+
},
|
|
1768
|
+
});
|
|
1769
|
+
```
|
|
1770
|
+
|
|
1771
|
+
Both options can be combined. The middleware order is: blocklist → IP rate limit → fingerprint rate limit.
|
|
1772
|
+
|
|
1773
|
+
#### Apply `botProtection` to individual routes
|
|
1774
|
+
|
|
1775
|
+
`botProtection` is also exported for per-route use:
|
|
1776
|
+
|
|
1777
|
+
```ts
|
|
1778
|
+
import { botProtection } from "@lastshotlabs/bunshot";
|
|
1779
|
+
|
|
1780
|
+
router.use("/api/submit", botProtection({ blockList: ["198.51.100.0/24"] }));
|
|
1781
|
+
```
|
|
1782
|
+
|
|
1783
|
+
---
|
|
1784
|
+
|
|
1785
|
+
### Setting a password after social login
|
|
1786
|
+
|
|
1787
|
+
If a user signed up via Google or Apple and later wants to add a password, send an authenticated request to `POST /auth/set-password`:
|
|
1788
|
+
|
|
1789
|
+
```ts
|
|
1790
|
+
// Client (logged-in user)
|
|
1791
|
+
await fetch("/auth/set-password", {
|
|
1792
|
+
method: "POST",
|
|
1793
|
+
headers: { "Content-Type": "application/json", "x-user-token": token },
|
|
1794
|
+
body: JSON.stringify({ password: "mynewpassword" }),
|
|
1795
|
+
});
|
|
1796
|
+
```
|
|
1797
|
+
|
|
1798
|
+
The built-in route hashes the password and calls `adapter.setPassword(userId, hash)`. If your adapter does not implement `setPassword`, the route returns `501 Not Implemented`.
|
|
1799
|
+
|
|
1800
|
+
To support it with a custom adapter:
|
|
1801
|
+
|
|
1802
|
+
```ts
|
|
1803
|
+
const myAdapter: AuthAdapter = {
|
|
1804
|
+
findByEmail: ...,
|
|
1805
|
+
create: ...,
|
|
1806
|
+
async setPassword(userId, passwordHash) {
|
|
1807
|
+
await db.update(users).set({ passwordHash }).where(eq(users.id, userId));
|
|
1808
|
+
},
|
|
1809
|
+
};
|
|
1810
|
+
```
|
|
1811
|
+
|
|
1812
|
+
---
|
|
1813
|
+
|
|
1814
|
+
## Roles
|
|
1815
|
+
|
|
1816
|
+
### Setup
|
|
1817
|
+
|
|
1818
|
+
Declare the valid roles for your app in `createServer` / `createApp`:
|
|
1819
|
+
|
|
1820
|
+
```ts
|
|
1821
|
+
await createServer({
|
|
1822
|
+
auth: {
|
|
1823
|
+
roles: ["admin", "editor", "user"],
|
|
1824
|
+
defaultRole: "user", // automatically assigned on /auth/register
|
|
1825
|
+
},
|
|
1826
|
+
// ...
|
|
1827
|
+
});
|
|
1828
|
+
```
|
|
1829
|
+
|
|
1830
|
+
`roles` makes the list available anywhere via `getAppRoles()`. `defaultRole` is assigned to every new user that registers via `POST /auth/register` — no extra code needed.
|
|
1831
|
+
|
|
1832
|
+
### Assigning roles to a user
|
|
1833
|
+
|
|
1834
|
+
Three helpers are available depending on what you need:
|
|
1835
|
+
|
|
1836
|
+
| Helper | Behaviour |
|
|
1837
|
+
|---|---|
|
|
1838
|
+
| `setUserRoles(userId, roles)` | Replace all roles — pass the full desired set |
|
|
1839
|
+
| `addUserRole(userId, role)` | Add a single role, leaving others unchanged |
|
|
1840
|
+
| `removeUserRole(userId, role)` | Remove a single role, leaving others unchanged |
|
|
1841
|
+
|
|
1842
|
+
```ts
|
|
1843
|
+
import { setUserRoles, addUserRole, removeUserRole, userAuth, requireRole } from "@lastshotlabs/bunshot";
|
|
1844
|
+
|
|
1845
|
+
// promote a user to admin
|
|
1846
|
+
router.post("/admin/users/:id/promote", userAuth, requireRole("admin"), async (c) => {
|
|
1847
|
+
await addUserRole(c.req.param("id"), "admin");
|
|
1848
|
+
return c.json({ ok: true });
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
// revoke a role
|
|
1852
|
+
router.post("/admin/users/:id/demote", userAuth, requireRole("admin"), async (c) => {
|
|
1853
|
+
await removeUserRole(c.req.param("id"), "admin");
|
|
1854
|
+
return c.json({ ok: true });
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
// replace all roles at once
|
|
1858
|
+
router.put("/admin/users/:id/roles", userAuth, requireRole("admin"), async (c) => {
|
|
1859
|
+
const { roles } = await c.req.json();
|
|
1860
|
+
await setUserRoles(c.req.param("id"), roles);
|
|
1861
|
+
return c.json({ ok: true });
|
|
1862
|
+
});
|
|
1863
|
+
```
|
|
1864
|
+
|
|
1865
|
+
### Protecting routes by role
|
|
1866
|
+
|
|
1867
|
+
`requireRole` is a middleware factory. It lazy-fetches roles on the first role-checked request and caches them on the Hono context, so multiple `requireRole` calls in a middleware chain only hit the DB once.
|
|
1868
|
+
|
|
1869
|
+
```ts
|
|
1870
|
+
import { userAuth, requireRole } from "@lastshotlabs/bunshot";
|
|
1871
|
+
|
|
1872
|
+
router.use("/admin", userAuth, requireRole("admin"));
|
|
1873
|
+
router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
|
|
1874
|
+
```
|
|
1875
|
+
|
|
1876
|
+
| Scenario | Response |
|
|
1877
|
+
|---|---|
|
|
1878
|
+
| No session | `401 Unauthorized` |
|
|
1879
|
+
| Authenticated, wrong role | `403 Forbidden` |
|
|
1880
|
+
| Authenticated, correct role | passes through |
|
|
1881
|
+
|
|
1882
|
+
### Custom adapter with roles
|
|
1883
|
+
|
|
1884
|
+
If you're using a custom `authAdapter`, implement the role methods to back role operations with your own store:
|
|
1885
|
+
|
|
1886
|
+
| Method | Required for |
|
|
1887
|
+
|---|---|
|
|
1888
|
+
| `getRoles(userId)` | `requireRole` middleware |
|
|
1889
|
+
| `setRoles(userId, roles)` | `defaultRole` assignment on registration, full replace |
|
|
1890
|
+
| `addRole(userId, role)` | Granular role addition |
|
|
1891
|
+
| `removeRole(userId, role)` | Granular role removal |
|
|
1892
|
+
|
|
1893
|
+
All are optional — only implement what your app uses. `setRoles` is **required** if you configure `defaultRole` (the app will throw at startup if this combination is misconfigured). The exported helpers `setUserRoles`, `addUserRole`, and `removeUserRole` route through your adapter, so they work regardless of which store you use.
|
|
1894
|
+
|
|
1895
|
+
```ts
|
|
1896
|
+
const myAdapter: AuthAdapter = {
|
|
1897
|
+
findByEmail: ...,
|
|
1898
|
+
create: ...,
|
|
1899
|
+
async getRoles(userId) {
|
|
1900
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
1901
|
+
return user?.roles ?? [];
|
|
1902
|
+
},
|
|
1903
|
+
async setRoles(userId, roles) {
|
|
1904
|
+
await db.update(users).set({ roles }).where(eq(users.id, userId));
|
|
1905
|
+
},
|
|
1906
|
+
async addRole(userId, role) {
|
|
1907
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
1908
|
+
if (user && !user.roles.includes(role)) {
|
|
1909
|
+
await db.update(users).set({ roles: [...user.roles, role] }).where(eq(users.id, userId));
|
|
1910
|
+
}
|
|
1911
|
+
},
|
|
1912
|
+
async removeRole(userId, role) {
|
|
1913
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
1914
|
+
if (user) {
|
|
1915
|
+
await db.update(users).set({ roles: user.roles.filter((r: string) => r !== role) }).where(eq(users.id, userId));
|
|
1916
|
+
}
|
|
1917
|
+
},
|
|
1918
|
+
};
|
|
1919
|
+
```
|
|
1920
|
+
|
|
1921
|
+
### Tenant-scoped roles
|
|
1922
|
+
|
|
1923
|
+
When multi-tenancy is enabled (see below), `requireRole` automatically checks **tenant-scoped roles** instead of app-wide roles when a `tenantId` is present in the request context.
|
|
1924
|
+
|
|
1925
|
+
```ts
|
|
1926
|
+
// Assign a tenant-scoped role
|
|
1927
|
+
import { addTenantRole, setTenantRoles, removeTenantRole, getTenantRoles } from "@lastshotlabs/bunshot";
|
|
1928
|
+
|
|
1929
|
+
await addTenantRole(userId, "acme", "admin");
|
|
1930
|
+
await setTenantRoles(userId, "acme", ["admin", "editor"]);
|
|
1931
|
+
await removeTenantRole(userId, "acme", "editor");
|
|
1932
|
+
const roles = await getTenantRoles(userId, "acme"); // ["admin"]
|
|
1933
|
+
```
|
|
1934
|
+
|
|
1935
|
+
`requireRole("admin")` checks tenant-scoped roles when `tenantId` is in context, and falls back to app-wide roles when there is no tenant context. Use `requireRole.global("superadmin")` to always check app-wide roles regardless of tenant.
|
|
1936
|
+
|
|
1937
|
+
```ts
|
|
1938
|
+
router.use("/tenant-admin", userAuth, requireRole("admin")); // checks tenant roles when in tenant context
|
|
1939
|
+
router.use("/super-admin", userAuth, requireRole.global("superadmin")); // always checks app-wide roles
|
|
1940
|
+
```
|
|
1941
|
+
|
|
1942
|
+
If you're using a custom `authAdapter`, implement the tenant role methods:
|
|
1943
|
+
|
|
1944
|
+
| Method | Purpose |
|
|
1945
|
+
|---|---|
|
|
1946
|
+
| `getTenantRoles(userId, tenantId)` | Required for tenant-scoped `requireRole` |
|
|
1947
|
+
| `setTenantRoles(userId, tenantId, roles)` | Full replace |
|
|
1948
|
+
| `addTenantRole(userId, tenantId, role)` | Granular addition |
|
|
1949
|
+
| `removeTenantRole(userId, tenantId, role)` | Granular removal |
|
|
1950
|
+
|
|
1951
|
+
---
|
|
1952
|
+
|
|
1953
|
+
## Multi-Tenancy
|
|
1954
|
+
|
|
1955
|
+
Add multi-tenancy to your app by configuring tenant resolution. Bunshot resolves the tenant on each request and attaches `tenantId` + `tenantConfig` to the Hono context.
|
|
1956
|
+
|
|
1957
|
+
```ts
|
|
1958
|
+
await createServer({
|
|
1959
|
+
tenancy: {
|
|
1960
|
+
resolution: "header", // "header" | "subdomain" | "path"
|
|
1961
|
+
headerName: "x-tenant-id", // default for "header" strategy
|
|
1962
|
+
onResolve: async (tenantId) => { // validate + load tenant config — return null to reject
|
|
1963
|
+
const tenant = await getTenant(tenantId);
|
|
1964
|
+
return tenant?.config ?? null;
|
|
1965
|
+
},
|
|
1966
|
+
cacheTtlMs: 60_000, // LRU cache TTL for onResolve (default: 60s, 0 to disable)
|
|
1967
|
+
cacheMaxSize: 500, // max cached entries (default: 500)
|
|
1968
|
+
exemptPaths: ["/webhooks"], // additional paths that skip tenant resolution
|
|
1969
|
+
rejectionStatus: 403, // 403 (default) or 404 when onResolve returns null
|
|
1970
|
+
},
|
|
1971
|
+
});
|
|
1972
|
+
```
|
|
1973
|
+
|
|
1974
|
+
### Resolution strategies
|
|
1975
|
+
|
|
1976
|
+
| Strategy | How it extracts tenant ID | Example |
|
|
1977
|
+
|---|---|---|
|
|
1978
|
+
| `"header"` | From request header (default `x-tenant-id`) | `x-tenant-id: acme` |
|
|
1979
|
+
| `"subdomain"` | From first subdomain | `acme.myapp.com` → `"acme"` |
|
|
1980
|
+
| `"path"` | From URL path segment (does **not** strip prefix) | `/acme/api/users` → `"acme"` |
|
|
1981
|
+
|
|
1982
|
+
### Default exempt paths
|
|
1983
|
+
|
|
1984
|
+
These paths skip tenant resolution by default: `/health`, `/docs`, `/openapi.json`, `/auth/` (auth is global — all tenants share a user pool). Add more via `exemptPaths`.
|
|
1985
|
+
|
|
1986
|
+
### Accessing tenant in routes
|
|
1987
|
+
|
|
1988
|
+
```ts
|
|
1989
|
+
router.openapi(myRoute, async (c) => {
|
|
1990
|
+
const tenantId = c.get("tenantId"); // string | null
|
|
1991
|
+
const tenantConfig = c.get("tenantConfig"); // Record<string, unknown> | null
|
|
1992
|
+
// Filter queries by tenantId, apply tenant-specific settings, etc.
|
|
1993
|
+
});
|
|
1994
|
+
```
|
|
1995
|
+
|
|
1996
|
+
### Tenant provisioning helpers
|
|
1997
|
+
|
|
1998
|
+
CRUD utilities for managing tenants (stored in the auth database via MongoDB):
|
|
1999
|
+
|
|
2000
|
+
```ts
|
|
2001
|
+
import { createTenant, getTenant, listTenants, deleteTenant } from "@lastshotlabs/bunshot";
|
|
2002
|
+
|
|
2003
|
+
await createTenant("acme", { displayName: "Acme Corp", config: { maxUsers: 100 } });
|
|
2004
|
+
const tenant = await getTenant("acme"); // { tenantId, displayName, config, createdAt }
|
|
2005
|
+
const all = await listTenants(); // active tenants only
|
|
2006
|
+
await deleteTenant("acme"); // soft-delete + invalidates resolution cache
|
|
2007
|
+
```
|
|
2008
|
+
|
|
2009
|
+
### Per-tenant namespacing
|
|
2010
|
+
|
|
2011
|
+
When tenant context is present, rate limits and cache keys are automatically namespaced per-tenant — no code changes needed. Each tenant gets independent rate limit buckets and cache entries.
|
|
2012
|
+
|
|
2013
|
+
- Rate limit keys: `t:${tenantId}:ip:${ip}` (instead of `ip:${ip}`)
|
|
2014
|
+
- Cache keys: `cache:${appName}:${tenantId}:${key}` (instead of `cache:${appName}:${key}`)
|
|
2015
|
+
|
|
2016
|
+
---
|
|
2017
|
+
|
|
2018
|
+
## Social Login (OAuth)
|
|
2019
|
+
|
|
2020
|
+
Pass `auth.oauth.providers` to `createServer` to enable Google and/or Apple sign-in. Routes are mounted automatically for each configured provider.
|
|
2021
|
+
|
|
2022
|
+
```ts
|
|
2023
|
+
await createServer({
|
|
2024
|
+
routesDir: import.meta.dir + "/routes",
|
|
2025
|
+
app: { name: "My App", version: "1.0.0" },
|
|
2026
|
+
auth: {
|
|
2027
|
+
oauth: {
|
|
2028
|
+
postRedirect: "/lobby", // where to redirect after login (default: "/")
|
|
2029
|
+
providers: {
|
|
2030
|
+
google: {
|
|
2031
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
2032
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
2033
|
+
redirectUri: "https://myapp.com/auth/google/callback",
|
|
2034
|
+
},
|
|
2035
|
+
apple: {
|
|
2036
|
+
clientId: process.env.APPLE_CLIENT_ID!, // Services ID, e.g. "com.myapp.auth"
|
|
2037
|
+
teamId: process.env.APPLE_TEAM_ID!,
|
|
2038
|
+
keyId: process.env.APPLE_KEY_ID!,
|
|
2039
|
+
privateKey: process.env.APPLE_PRIVATE_KEY!, // PEM string
|
|
2040
|
+
redirectUri: "https://myapp.com/auth/apple/callback",
|
|
2041
|
+
},
|
|
2042
|
+
},
|
|
2043
|
+
},
|
|
2044
|
+
},
|
|
2045
|
+
});
|
|
2046
|
+
```
|
|
2047
|
+
|
|
2048
|
+
### Routes mounted automatically
|
|
2049
|
+
|
|
2050
|
+
| Provider | Initiate login | Callback | Link to existing account | Unlink |
|
|
2051
|
+
|---|---|---|---|---|
|
|
2052
|
+
| Google | `GET /auth/google` | `GET /auth/google/callback` | `GET /auth/google/link` | `DELETE /auth/google/link` |
|
|
2053
|
+
| Apple | `GET /auth/apple` | `POST /auth/apple/callback` | `GET /auth/apple/link` | — |
|
|
2054
|
+
|
|
2055
|
+
> Apple sends its callback as a **POST** with form data. Your server must be publicly reachable and the redirect URI must be registered in the Apple developer console.
|
|
2056
|
+
|
|
2057
|
+
### Flow
|
|
2058
|
+
|
|
2059
|
+
1. Client navigates to `GET /auth/google` (or `/auth/apple`)
|
|
2060
|
+
2. Package redirects to the provider's OAuth page
|
|
2061
|
+
3. Provider redirects (or POSTs) back to the callback URL
|
|
2062
|
+
4. Package exchanges the code, fetches the user profile, and calls `authAdapter.findOrCreateByProvider`
|
|
2063
|
+
5. A session is created, the `auth-token` cookie is set, and the user is redirected to `auth.oauth.postRedirect`
|
|
2064
|
+
|
|
2065
|
+
### User storage
|
|
2066
|
+
|
|
2067
|
+
The default `mongoAuthAdapter` stores social users in `AuthUser` with a `providerIds` field (e.g. `["google:1234567890"]`). If no existing provider key is found, a new account is created — emails are never auto-linked. To connect a social identity to an existing credential account the user must explicitly use the link flow below.
|
|
2068
|
+
|
|
2069
|
+
**Email conflict handling:** If a user attempts to sign in via Google (or Apple) and the email returned by the provider already belongs to a credential-based account, `findOrCreateByProvider` throws `HttpError(409, ...)`. The OAuth callback catches this and redirects to `auth.oauth.postRedirect?error=<message>` so the client can display a helpful prompt (e.g. "An account with this email already exists — sign in with your password, then link Google from your account settings.").
|
|
2070
|
+
|
|
2071
|
+
To support social login with a custom adapter, implement `findOrCreateByProvider`:
|
|
2072
|
+
|
|
2073
|
+
```ts
|
|
2074
|
+
const myAdapter: AuthAdapter = {
|
|
2075
|
+
findByEmail: ...,
|
|
2076
|
+
create: ...,
|
|
2077
|
+
async findOrCreateByProvider(provider, providerId, profile) {
|
|
2078
|
+
// find or upsert user by provider + providerId
|
|
2079
|
+
// return { id: string }
|
|
2080
|
+
},
|
|
2081
|
+
};
|
|
2082
|
+
```
|
|
2083
|
+
|
|
2084
|
+
### Linking a provider to an existing account
|
|
2085
|
+
|
|
2086
|
+
A logged-in user can link their account to a Google or Apple identity by navigating to the link route. This is the only way to associate a social login with an existing credential account — email matching is intentionally not done automatically.
|
|
2087
|
+
|
|
2088
|
+
```
|
|
2089
|
+
GET /auth/google/link (requires active session via cookie)
|
|
2090
|
+
GET /auth/apple/link (requires active session via cookie)
|
|
2091
|
+
```
|
|
2092
|
+
|
|
2093
|
+
The link flow:
|
|
2094
|
+
1. User is already logged in (session cookie set)
|
|
2095
|
+
2. Client navigates to `/auth/google/link`
|
|
2096
|
+
3. User completes Google OAuth as normal
|
|
2097
|
+
4. On callback, instead of creating a new session, the Google identity is added to their existing account
|
|
2098
|
+
5. User is redirected to `auth.oauth.postRedirect?linked=google`
|
|
2099
|
+
|
|
2100
|
+
To support linking with a custom adapter, implement `linkProvider`:
|
|
2101
|
+
|
|
2102
|
+
```ts
|
|
2103
|
+
const myAdapter: AuthAdapter = {
|
|
2104
|
+
// ...
|
|
2105
|
+
async linkProvider(userId, provider, providerId) {
|
|
2106
|
+
const key = `${provider}:${providerId}`;
|
|
2107
|
+
await db.update(users)
|
|
2108
|
+
.set({ providerIds: sql`array_append(provider_ids, ${key})` })
|
|
2109
|
+
.where(eq(users.id, userId));
|
|
2110
|
+
},
|
|
2111
|
+
};
|
|
2112
|
+
```
|
|
2113
|
+
|
|
2114
|
+
### Unlinking a provider
|
|
2115
|
+
|
|
2116
|
+
A logged-in user can remove a linked Google identity via:
|
|
2117
|
+
|
|
2118
|
+
```
|
|
2119
|
+
DELETE /auth/google/link (requires active session via cookie)
|
|
2120
|
+
```
|
|
2121
|
+
|
|
2122
|
+
Returns `204 No Content` on success. All `google:*` entries are removed from the user's `providerIds`.
|
|
2123
|
+
|
|
2124
|
+
To support unlinking with a custom adapter, implement `unlinkProvider`:
|
|
2125
|
+
|
|
2126
|
+
```ts
|
|
2127
|
+
const myAdapter: AuthAdapter = {
|
|
2128
|
+
// ...
|
|
2129
|
+
async unlinkProvider(userId, provider) {
|
|
2130
|
+
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
|
2131
|
+
if (!user) throw new HttpError(404, "User not found");
|
|
2132
|
+
const filtered = user.providerIds.filter((id: string) => !id.startsWith(`${provider}:`));
|
|
2133
|
+
await db.update(users).set({ providerIds: filtered }).where(eq(users.id, userId));
|
|
2134
|
+
},
|
|
2135
|
+
};
|
|
2136
|
+
```
|
|
2137
|
+
|
|
2138
|
+
---
|
|
2139
|
+
|
|
2140
|
+
## Peer Dependencies
|
|
2141
|
+
|
|
2142
|
+
Bunshot declares the following as peer dependencies so you control their versions and avoid duplicate installs in your app.
|
|
2143
|
+
|
|
2144
|
+
### Required
|
|
2145
|
+
|
|
2146
|
+
These must be installed in every consuming app:
|
|
2147
|
+
|
|
2148
|
+
```bash
|
|
2149
|
+
bun add hono zod
|
|
2150
|
+
```
|
|
2151
|
+
|
|
2152
|
+
| Package | Required version |
|
|
2153
|
+
|---|---|
|
|
2154
|
+
| `hono` | `>=4.12 <5` |
|
|
2155
|
+
| `zod` | `>=4.0 <5` |
|
|
2156
|
+
|
|
2157
|
+
### Optional
|
|
2158
|
+
|
|
2159
|
+
Install only what your app actually uses:
|
|
2160
|
+
|
|
2161
|
+
```bash
|
|
2162
|
+
# MongoDB auth / sessions / cache
|
|
2163
|
+
bun add mongoose
|
|
2164
|
+
|
|
2165
|
+
# Redis sessions, cache, rate limiting, or BullMQ
|
|
2166
|
+
bun add ioredis
|
|
2167
|
+
|
|
2168
|
+
# Background job queues
|
|
2169
|
+
bun add bullmq
|
|
2170
|
+
|
|
2171
|
+
# MFA / TOTP
|
|
2172
|
+
bun add otpauth
|
|
2173
|
+
```
|
|
2174
|
+
|
|
2175
|
+
| Package | Required version | When you need it |
|
|
2176
|
+
|---|---|---|
|
|
2177
|
+
| `mongoose` | `>=9.0 <10` | `db.auth: "mongo"`, `db.sessions: "mongo"`, or `db.cache: "mongo"` |
|
|
2178
|
+
| `ioredis` | `>=5.0 <6` | `db.redis: true` (the default), or any store set to `"redis"` |
|
|
2179
|
+
| `bullmq` | `>=5.0 <6` | Workers / queues |
|
|
2180
|
+
| `otpauth` | `>=9.0 <10` | `auth.mfa` configuration |
|
|
2181
|
+
|
|
2182
|
+
If you're running fully on SQLite or memory (no Redis, no MongoDB), none of the optional peers are needed.
|
|
2183
|
+
|
|
2184
|
+
---
|
|
2185
|
+
|
|
2186
|
+
## Environment Variables
|
|
2187
|
+
|
|
2188
|
+
```env
|
|
2189
|
+
NODE_ENV=development
|
|
2190
|
+
PORT=...
|
|
2191
|
+
|
|
2192
|
+
# MongoDB (single connection — used by connectMongo())
|
|
2193
|
+
MONGO_USER_DEV=...
|
|
2194
|
+
MONGO_PW_DEV=...
|
|
2195
|
+
MONGO_HOST_DEV=...
|
|
2196
|
+
MONGO_DB_DEV=...
|
|
2197
|
+
MONGO_USER_PROD=...
|
|
2198
|
+
MONGO_PW_PROD=...
|
|
2199
|
+
MONGO_HOST_PROD=...
|
|
2200
|
+
MONGO_DB_PROD=...
|
|
2201
|
+
|
|
2202
|
+
# MongoDB auth connection (separate server — used by connectAuthMongo())
|
|
2203
|
+
# Only needed when running auth on a different cluster from app data
|
|
2204
|
+
MONGO_AUTH_USER_DEV=...
|
|
2205
|
+
MONGO_AUTH_PW_DEV=...
|
|
2206
|
+
MONGO_AUTH_HOST_DEV=...
|
|
2207
|
+
MONGO_AUTH_DB_DEV=...
|
|
2208
|
+
MONGO_AUTH_USER_PROD=...
|
|
2209
|
+
MONGO_AUTH_PW_PROD=...
|
|
2210
|
+
MONGO_AUTH_HOST_PROD=...
|
|
2211
|
+
MONGO_AUTH_DB_PROD=...
|
|
2212
|
+
|
|
2213
|
+
# Redis
|
|
2214
|
+
REDIS_HOST_DEV=host:port
|
|
2215
|
+
REDIS_USER_DEV=...
|
|
2216
|
+
REDIS_PW_DEV=...
|
|
2217
|
+
REDIS_HOST_PROD=host:port
|
|
2218
|
+
REDIS_USER_PROD=...
|
|
2219
|
+
REDIS_PW_PROD=...
|
|
2220
|
+
|
|
2221
|
+
# JWT
|
|
2222
|
+
JWT_SECRET_DEV=...
|
|
2223
|
+
JWT_SECRET_PROD=...
|
|
2224
|
+
|
|
2225
|
+
# Bearer API key (required on every non-bypassed request)
|
|
2226
|
+
BEARER_TOKEN_DEV=...
|
|
2227
|
+
BEARER_TOKEN_PROD=...
|
|
2228
|
+
|
|
2229
|
+
# Logging (optional — defaults to on in dev)
|
|
2230
|
+
LOGGING_VERBOSE=true
|
|
2231
|
+
|
|
2232
|
+
# OAuth (only needed if using oauthProviders)
|
|
2233
|
+
GOOGLE_CLIENT_ID=...
|
|
2234
|
+
GOOGLE_CLIENT_SECRET=...
|
|
2235
|
+
|
|
2236
|
+
APPLE_CLIENT_ID=...
|
|
2237
|
+
APPLE_TEAM_ID=...
|
|
2238
|
+
APPLE_KEY_ID=...
|
|
2239
|
+
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
|
|
2240
|
+
```
|
|
2241
|
+
|
|
2242
|
+
---
|
|
2243
|
+
|
|
2244
|
+
## Documentation Generation
|
|
2245
|
+
|
|
2246
|
+
Bunshot ships its documentation as modular markdown sections that you can pull into your own project's README.
|
|
2247
|
+
|
|
2248
|
+
### Setup
|
|
2249
|
+
|
|
2250
|
+
Create a `docs/` directory in your project with a config and build script:
|
|
2251
|
+
|
|
2252
|
+
```
|
|
2253
|
+
my-app/
|
|
2254
|
+
docs/
|
|
2255
|
+
readme.config.json
|
|
2256
|
+
build-readme.ts
|
|
2257
|
+
sections/
|
|
2258
|
+
intro/
|
|
2259
|
+
full.md
|
|
2260
|
+
my-api/
|
|
2261
|
+
full.md
|
|
2262
|
+
overview.md
|
|
2263
|
+
```
|
|
2264
|
+
|
|
2265
|
+
### Config — `docs/readme.config.json`
|
|
2266
|
+
|
|
2267
|
+
```json
|
|
2268
|
+
{
|
|
2269
|
+
"output": "../README.md",
|
|
2270
|
+
"separator": "---",
|
|
2271
|
+
"sections": [
|
|
2272
|
+
{ "topic": "intro", "default": "full", "separator": false },
|
|
2273
|
+
{ "topic": "my-api", "default": "full" },
|
|
2274
|
+
{ "topic": "bunshot-auth", "file": "@lastshotlabs/bunshot/docs/auth-flow/overview.md" },
|
|
2275
|
+
{ "topic": "bunshot-config", "file": "@lastshotlabs/bunshot/docs/configuration/full.md" }
|
|
2276
|
+
],
|
|
2277
|
+
"profiles": {
|
|
2278
|
+
"short": {
|
|
2279
|
+
"my-api": "overview"
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
```
|
|
2284
|
+
|
|
2285
|
+
**Section entries:**
|
|
2286
|
+
|
|
2287
|
+
| Field | Description |
|
|
2288
|
+
|-------|-------------|
|
|
2289
|
+
| `topic` | Section identifier. Maps to `sections/{topic}/` directory when no `file` is specified. |
|
|
2290
|
+
| `default` | Variant to use: `"full"` or `"overview"`. Falls back to `"full"` if the requested variant doesn't exist. |
|
|
2291
|
+
| `file` | Explicit file path. Supports relative paths (`sections/header.md`) and package paths (`@lastshotlabs/bunshot/docs/auth-flow/overview.md`). |
|
|
2292
|
+
| `separator` | `true`/`false` — whether to insert `---` before this section. Defaults to `true` (except the first section). |
|
|
2293
|
+
|
|
2294
|
+
**Profiles** override specific sections' variants. Only list sections you want to change:
|
|
2295
|
+
|
|
2296
|
+
```json
|
|
2297
|
+
"profiles": {
|
|
2298
|
+
"short": { "my-api": "overview", "bunshot-auth": "overview" }
|
|
2299
|
+
}
|
|
2300
|
+
```
|
|
2301
|
+
|
|
2302
|
+
### Build script — `docs/build-readme.ts`
|
|
2303
|
+
|
|
2304
|
+
Copy this into your project:
|
|
2305
|
+
|
|
2306
|
+
```ts
|
|
2307
|
+
const configPath = import.meta.dir + "/readme.config.json";
|
|
2308
|
+
const config = await Bun.file(configPath).json();
|
|
2309
|
+
const profile = Bun.argv[2];
|
|
2310
|
+
const overrides: Record<string, string> = profile
|
|
2311
|
+
? config.profiles?.[profile] ?? {}
|
|
2312
|
+
: {};
|
|
2313
|
+
const separator: string = config.separator ?? "---";
|
|
2314
|
+
|
|
2315
|
+
if (profile && !config.profiles?.[profile]) {
|
|
2316
|
+
console.error(`Unknown profile: "${profile}". Available: ${Object.keys(config.profiles ?? {}).join(", ")}`);
|
|
2317
|
+
process.exit(1);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
function resolveFilePath(file: string): string {
|
|
2321
|
+
if (file.startsWith("./") || file.startsWith("/") || file.startsWith("../")) {
|
|
2322
|
+
return import.meta.dir + "/" + file;
|
|
2323
|
+
}
|
|
2324
|
+
if (file.includes("/") && !file.startsWith("sections")) {
|
|
2325
|
+
const resolved = import.meta.resolve(file);
|
|
2326
|
+
return resolved.replace(/^file:\/\/\//, "");
|
|
2327
|
+
}
|
|
2328
|
+
return import.meta.dir + "/" + file;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
const parts: string[] = [
|
|
2332
|
+
"<!-- AUTO-GENERATED — edit docs/sections/, not this file. Run: bun run readme -->",
|
|
2333
|
+
];
|
|
2334
|
+
|
|
2335
|
+
for (let i = 0; i < config.sections.length; i++) {
|
|
2336
|
+
const section = config.sections[i];
|
|
2337
|
+
|
|
2338
|
+
let filePath: string;
|
|
2339
|
+
if (section.file) {
|
|
2340
|
+
filePath = resolveFilePath(section.file);
|
|
2341
|
+
} else {
|
|
2342
|
+
const variant = overrides[section.topic] ?? section.default ?? "full";
|
|
2343
|
+
const candidate = `${import.meta.dir}/sections/${section.topic}/${variant}.md`;
|
|
2344
|
+
filePath = (await Bun.file(candidate).exists())
|
|
2345
|
+
? candidate
|
|
2346
|
+
: `${import.meta.dir}/sections/${section.topic}/full.md`;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
const content = (await Bun.file(filePath).text()).replace(/\r\n/g, "\n");
|
|
2350
|
+
|
|
2351
|
+
const useSeparator = section.separator !== undefined ? section.separator : i > 0;
|
|
2352
|
+
if (useSeparator) parts.push(separator);
|
|
2353
|
+
|
|
2354
|
+
parts.push(content.trimEnd());
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
const outputPath = import.meta.dir + "/" + (config.output ?? "../README.md");
|
|
2358
|
+
await Bun.write(outputPath, parts.join("\n\n") + "\n");
|
|
2359
|
+
console.log(
|
|
2360
|
+
`README.md compiled (${config.sections.length} sections${profile ? `, profile: ${profile}` : ""})`
|
|
2361
|
+
);
|
|
2362
|
+
```
|
|
2363
|
+
|
|
2364
|
+
### Add to package.json
|
|
2365
|
+
|
|
2366
|
+
```json
|
|
2367
|
+
"scripts": {
|
|
2368
|
+
"readme": "bun docs/build-readme.ts",
|
|
2369
|
+
"readme:short": "bun docs/build-readme.ts short"
|
|
2370
|
+
}
|
|
2371
|
+
```
|
|
2372
|
+
|
|
2373
|
+
### Available bunshot sections
|
|
2374
|
+
|
|
2375
|
+
Pull any of these into your project's README via `"file": "@lastshotlabs/bunshot/docs/{section}/{variant}.md"`:
|
|
2376
|
+
|
|
2377
|
+
| Section | Variants |
|
|
2378
|
+
|---------|----------|
|
|
2379
|
+
| `quick-start` | `full` |
|
|
2380
|
+
| `stack` | `full` |
|
|
2381
|
+
| `cli` | `full` |
|
|
2382
|
+
| `installation` | `full` |
|
|
2383
|
+
| `configuration-example` | `full`, `overview` |
|
|
2384
|
+
| `adding-routes` | `full`, `overview` |
|
|
2385
|
+
| `mongodb-connections` | `full`, `overview` |
|
|
2386
|
+
| `adding-models` | `full`, `overview` |
|
|
2387
|
+
| `jobs` | `full`, `overview` |
|
|
2388
|
+
| `websocket` | `full`, `overview` |
|
|
2389
|
+
| `websocket-rooms` | `full`, `overview` |
|
|
2390
|
+
| `adding-middleware` | `full` |
|
|
2391
|
+
| `response-caching` | `full`, `overview` |
|
|
2392
|
+
| `extending-context` | `full` |
|
|
2393
|
+
| `configuration` | `full`, `overview` |
|
|
2394
|
+
| `running-without-redis` | `full` |
|
|
2395
|
+
| `running-without-redis-or-mongodb` | `full` |
|
|
2396
|
+
| `auth-flow` | `full`, `overview` |
|
|
2397
|
+
| `roles` | `full`, `overview` |
|
|
2398
|
+
| `multi-tenancy` | `full`, `overview` |
|
|
2399
|
+
| `oauth` | `full`, `overview` |
|
|
2400
|
+
| `peer-dependencies` | `full` |
|
|
2401
|
+
| `environment-variables` | `full` |
|
|
2402
|
+
| `exports` | `full` |
|
|
2403
|
+
|
|
2404
|
+
### Writing your own sections
|
|
2405
|
+
|
|
2406
|
+
Each section file is self-contained markdown starting with a `## Heading`. Create `docs/sections/{topic}/full.md` and optionally `overview.md`:
|
|
2407
|
+
|
|
2408
|
+
```markdown
|
|
2409
|
+
## My Feature
|
|
2410
|
+
|
|
2411
|
+
Description and code examples here...
|
|
2412
|
+
```
|
|
2413
|
+
|
|
2414
|
+
The `---` separators between sections are inserted by the build script — don't include them in section files.
|
|
2415
|
+
|
|
2416
|
+
---
|
|
2417
|
+
|
|
2418
|
+
## Package Development
|
|
2419
|
+
|
|
2420
|
+
To test changes locally, install the package from the local path in a sibling project:
|
|
2421
|
+
|
|
2422
|
+
```bash
|
|
2423
|
+
bun add @lastshotlabs/bunshot@file:../bunshot
|
|
2424
|
+
```
|
|
2425
|
+
|
|
2426
|
+
---
|
|
2427
|
+
|
|
2428
|
+
## Exports
|
|
2429
|
+
|
|
2430
|
+
```ts
|
|
2431
|
+
import {
|
|
2432
|
+
// Server factory
|
|
2433
|
+
createServer, createApp,
|
|
2434
|
+
|
|
2435
|
+
// DB
|
|
2436
|
+
connectMongo, connectAuthMongo, connectAppMongo, disconnectMongo,
|
|
2437
|
+
authConnection, appConnection, mongoose,
|
|
2438
|
+
connectRedis, disconnectRedis, getRedis,
|
|
2439
|
+
|
|
2440
|
+
// Jobs
|
|
2441
|
+
createQueue, createWorker,
|
|
2442
|
+
type Job,
|
|
2443
|
+
|
|
2444
|
+
// WebSocket
|
|
2445
|
+
websocket, createWsUpgradeHandler, publish,
|
|
2446
|
+
subscribe, unsubscribe, getSubscriptions, handleRoomActions,
|
|
2447
|
+
getRooms, getRoomSubscribers,
|
|
2448
|
+
|
|
2449
|
+
// Auth utilities
|
|
2450
|
+
signToken, verifyToken,
|
|
2451
|
+
createSession, getSession, deleteSession, getUserSessions, getActiveSessionCount,
|
|
2452
|
+
evictOldestSession, updateSessionLastActive, setSessionStore, deleteUserSessions,
|
|
2453
|
+
setRefreshToken, getSessionByRefreshToken, rotateRefreshToken, // refresh token management
|
|
2454
|
+
createVerificationToken, getVerificationToken, deleteVerificationToken, // email verification tokens
|
|
2455
|
+
createResetToken, consumeResetToken, setPasswordResetStore, // password reset tokens
|
|
2456
|
+
createMfaChallenge, consumeMfaChallenge, replaceMfaChallengeOtp, setMfaChallengeStore, // MFA challenge tokens
|
|
2457
|
+
bustAuthLimit, trackAttempt, isLimited, // auth rate limiting — use in custom routes or admin unlocks
|
|
2458
|
+
buildFingerprint, // HTTP fingerprint hash (IP-independent) — use in custom bot detection logic
|
|
2459
|
+
sqliteAuthAdapter, setSqliteDb, startSqliteCleanup, // SQLite backend (persisted)
|
|
2460
|
+
memoryAuthAdapter, clearMemoryStore, // in-memory backend (ephemeral)
|
|
2461
|
+
setUserRoles, addUserRole, removeUserRole, // app-wide role management
|
|
2462
|
+
getTenantRoles, setTenantRoles, addTenantRole, removeTenantRole, // tenant-scoped role management
|
|
2463
|
+
type AuthAdapter, type OAuthProfile, type OAuthProviderConfig, type MfaChallengeData,
|
|
2464
|
+
type AuthRateLimitConfig, type BotProtectionConfig, type BotProtectionOptions,
|
|
2465
|
+
type LimitOpts, type RateLimitOptions,
|
|
2466
|
+
type SessionMetadata, type SessionInfo, type RefreshResult,
|
|
2467
|
+
|
|
2468
|
+
// Tenancy
|
|
2469
|
+
createTenant, deleteTenant, getTenant, listTenants, // tenant provisioning (MongoDB)
|
|
2470
|
+
invalidateTenantCache, // invalidate LRU cache entry
|
|
2471
|
+
type TenantInfo, type CreateTenantOptions,
|
|
2472
|
+
type TenancyConfig, type TenantConfig,
|
|
2473
|
+
|
|
2474
|
+
// Middleware
|
|
2475
|
+
bearerAuth, identify, userAuth, rateLimit,
|
|
2476
|
+
botProtection, // CIDR blocklist + per-route bot protection
|
|
2477
|
+
requireRole, // role-based access control (tenant-aware)
|
|
2478
|
+
requireVerifiedEmail, // blocks unverified email addresses
|
|
2479
|
+
cacheResponse, bustCache, bustCachePattern, setCacheStore, // response caching (tenant-namespaced)
|
|
2480
|
+
|
|
2481
|
+
// Utilities
|
|
2482
|
+
HttpError, log, validate, createRouter, createRoute,
|
|
2483
|
+
registerSchema, registerSchemas, // named OpenAPI schema registration
|
|
2484
|
+
zodToMongoose, // Zod → Mongoose schema conversion
|
|
2485
|
+
createDtoMapper, // DB document → API DTO mapper factory
|
|
2486
|
+
type ZodToMongooseConfig, type ZodToMongooseRefConfig, type DtoMapperConfig,
|
|
2487
|
+
getAppRoles, // returns the valid roles list configured at startup
|
|
2488
|
+
|
|
2489
|
+
// Constants
|
|
2490
|
+
COOKIE_TOKEN, HEADER_USER_TOKEN,
|
|
2491
|
+
COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN, // refresh token cookie/header names
|
|
2492
|
+
|
|
2493
|
+
// Types
|
|
2494
|
+
type AppEnv, type AppVariables,
|
|
2495
|
+
type CreateServerConfig, type CreateAppConfig, type ModelSchemasConfig,
|
|
2496
|
+
type DbConfig, type AppMeta, type AuthConfig, type OAuthConfig, type SecurityConfig,
|
|
2497
|
+
type PrimaryField, type EmailVerificationConfig, type PasswordResetConfig,
|
|
2498
|
+
type RefreshTokenConfig, type MfaConfig, type MfaEmailOtpConfig, type JobsConfig,
|
|
2499
|
+
type AccountDeletionConfig,
|
|
2500
|
+
type SocketData, type WsConfig,
|
|
2501
|
+
} from "@lastshotlabs/bunshot";
|
|
2502
|
+
|
|
2503
|
+
// Jobs (separate entrypoint)
|
|
2504
|
+
import {
|
|
2505
|
+
createQueue, createWorker,
|
|
2506
|
+
createCronWorker, cleanupStaleSchedulers, getRegisteredCronNames,
|
|
2507
|
+
createDLQHandler,
|
|
2508
|
+
type Job,
|
|
2509
|
+
} from "@lastshotlabs/bunshot/queue";
|
|
2510
|
+
```
|