@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.
Files changed (100) hide show
  1. package/README.md +2510 -1580
  2. package/dist/adapters/memoryAuth.d.ts +4 -0
  3. package/dist/adapters/memoryAuth.js +131 -2
  4. package/dist/adapters/mongoAuth.js +56 -0
  5. package/dist/adapters/sqliteAuth.d.ts +6 -0
  6. package/dist/adapters/sqliteAuth.js +137 -2
  7. package/dist/app.d.ts +107 -2
  8. package/dist/app.js +83 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +15 -5
  12. package/dist/index.js +10 -3
  13. package/dist/lib/appConfig.d.ts +46 -0
  14. package/dist/lib/appConfig.js +20 -0
  15. package/dist/lib/authAdapter.d.ts +30 -0
  16. package/dist/lib/constants.d.ts +2 -0
  17. package/dist/lib/constants.js +2 -0
  18. package/dist/lib/context.d.ts +2 -0
  19. package/dist/lib/createDtoMapper.d.ts +33 -0
  20. package/dist/lib/createDtoMapper.js +69 -0
  21. package/dist/lib/createRoute.d.ts +61 -0
  22. package/dist/lib/createRoute.js +147 -0
  23. package/dist/lib/jwt.d.ts +1 -1
  24. package/dist/lib/jwt.js +2 -2
  25. package/dist/lib/mfaChallenge.d.ts +20 -0
  26. package/dist/lib/mfaChallenge.js +184 -0
  27. package/dist/lib/queue.d.ts +33 -0
  28. package/dist/lib/queue.js +98 -0
  29. package/dist/lib/roles.d.ts +4 -0
  30. package/dist/lib/roles.js +27 -0
  31. package/dist/lib/session.d.ts +12 -0
  32. package/dist/lib/session.js +163 -5
  33. package/dist/lib/tenant.d.ts +15 -0
  34. package/dist/lib/tenant.js +65 -0
  35. package/dist/lib/zodToMongoose.d.ts +38 -0
  36. package/dist/lib/zodToMongoose.js +84 -0
  37. package/dist/middleware/cacheResponse.js +4 -1
  38. package/dist/middleware/rateLimit.d.ts +2 -1
  39. package/dist/middleware/rateLimit.js +5 -2
  40. package/dist/middleware/requireRole.d.ts +14 -3
  41. package/dist/middleware/requireRole.js +46 -6
  42. package/dist/middleware/tenant.d.ts +5 -0
  43. package/dist/middleware/tenant.js +116 -0
  44. package/dist/models/AuthUser.d.ts +8 -0
  45. package/dist/models/AuthUser.js +8 -0
  46. package/dist/models/TenantRole.d.ts +15 -0
  47. package/dist/models/TenantRole.js +23 -0
  48. package/dist/routes/auth.d.ts +5 -3
  49. package/dist/routes/auth.js +253 -80
  50. package/dist/routes/jobs.d.ts +2 -0
  51. package/dist/routes/jobs.js +270 -0
  52. package/dist/routes/mfa.d.ts +1 -0
  53. package/dist/routes/mfa.js +409 -0
  54. package/dist/routes/oauth.js +107 -16
  55. package/dist/server.js +9 -0
  56. package/dist/services/auth.d.ts +21 -2
  57. package/dist/services/auth.js +97 -17
  58. package/dist/services/mfa.d.ts +37 -0
  59. package/dist/services/mfa.js +276 -0
  60. package/docs/sections/adding-middleware/full.md +35 -0
  61. package/docs/sections/adding-models/full.md +125 -0
  62. package/docs/sections/adding-models/overview.md +13 -0
  63. package/docs/sections/adding-routes/full.md +182 -0
  64. package/docs/sections/adding-routes/overview.md +23 -0
  65. package/docs/sections/auth-flow/full.md +456 -0
  66. package/docs/sections/auth-flow/overview.md +10 -0
  67. package/docs/sections/cli/full.md +30 -0
  68. package/docs/sections/configuration/full.md +135 -0
  69. package/docs/sections/configuration/overview.md +17 -0
  70. package/docs/sections/configuration-example/full.md +99 -0
  71. package/docs/sections/configuration-example/overview.md +30 -0
  72. package/docs/sections/documentation/full.md +171 -0
  73. package/docs/sections/environment-variables/full.md +55 -0
  74. package/docs/sections/exports/full.md +83 -0
  75. package/docs/sections/extending-context/full.md +59 -0
  76. package/docs/sections/header.md +3 -0
  77. package/docs/sections/installation/full.md +6 -0
  78. package/docs/sections/jobs/full.md +140 -0
  79. package/docs/sections/jobs/overview.md +15 -0
  80. package/docs/sections/mongodb-connections/full.md +45 -0
  81. package/docs/sections/mongodb-connections/overview.md +7 -0
  82. package/docs/sections/multi-tenancy/full.md +62 -0
  83. package/docs/sections/multi-tenancy/overview.md +15 -0
  84. package/docs/sections/oauth/full.md +119 -0
  85. package/docs/sections/oauth/overview.md +16 -0
  86. package/docs/sections/package-development/full.md +7 -0
  87. package/docs/sections/peer-dependencies/full.md +43 -0
  88. package/docs/sections/quick-start/full.md +43 -0
  89. package/docs/sections/response-caching/full.md +115 -0
  90. package/docs/sections/response-caching/overview.md +13 -0
  91. package/docs/sections/roles/full.md +136 -0
  92. package/docs/sections/roles/overview.md +12 -0
  93. package/docs/sections/running-without-redis/full.md +16 -0
  94. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  95. package/docs/sections/stack/full.md +10 -0
  96. package/docs/sections/websocket/full.md +100 -0
  97. package/docs/sections/websocket/overview.md +5 -0
  98. package/docs/sections/websocket-rooms/full.md +97 -0
  99. package/docs/sections/websocket-rooms/overview.md +5 -0
  100. package/package.json +19 -10
package/README.md CHANGED
@@ -1,1580 +1,2510 @@
1
- # Bunshot by Last Shot Labs
2
-
3
- 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.
4
-
5
- ## Stack
6
-
7
- - **Runtime**: [Bun](https://bun.sh)
8
- - **Framework**: [Hono](https://hono.dev) + [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi)
9
- - **Docs UI**: [Scalar](https://scalar.com)
10
- - **Data / Auth**: MongoDB, SQLite, or in-memory — configurable via `db.auth` (default: MongoDB via [Mongoose](https://mongoosejs.com))
11
- - **Cache / Sessions**: Redis, MongoDB, SQLite, or in-memory — configurable via `db.sessions` / `db.cache` (default: Redis via [ioredis](https://github.com/redis/ioredis))
12
- - **Auth**: JWT via [jose](https://github.com/panva/jose), HttpOnly cookies + `x-user-token` header
13
- - **Queues**: [BullMQ](https://docs.bullmq.io) (requires Redis with `noeviction` policy)
14
- - **Validation**: [Zod v4](https://zod.dev)
15
-
16
- ---
17
-
18
- ## CLI Scaffold a New Project
19
-
20
- ```bash
21
- bunx @lastshotlabs/bunshot "My App"
22
- ```
23
-
24
- You can also pass a custom directory name:
25
-
26
- ```bash
27
- bunx @lastshotlabs/bunshot "My App" my-app-dir
28
- ```
29
-
30
- This creates a ready-to-run project with:
31
-
32
- ```
33
- my-app/
34
- src/
35
- index.ts # entry point
36
- config/index.ts # centralized app configuration
37
- lib/constants.ts # app name, version, roles
38
- routes/ # add your route files here
39
- workers/ # BullMQ workers (auto-discovered)
40
- middleware/ # custom middleware
41
- models/ # data models
42
- services/ # business logic
43
- tsconfig.json # pre-configured with path aliases
44
- .env # environment variables template
45
- ```
46
-
47
- Path aliases like `@config/*`, `@lib/*`, `@middleware/*`, `@models/*`, `@routes/*`, `@services/*`, and `@workers/*` are set up automatically in `tsconfig.json`.
48
-
49
- ---
50
-
51
- ## Installation
52
-
53
- ```bash
54
- # from a local path (while developing the package)
55
- bun add @lastshotlabs/bunshot@file:../bunshot
56
-
57
- # from GitHub Packages (once published)
58
- bun add @lastshotlabs/bunshot
59
- ```
60
-
61
- ---
62
-
63
- ## Peer Dependencies
64
-
65
- Bunshot declares the following as peer dependencies so you control their versions and avoid duplicate installs in your app.
66
-
67
- ### Required
68
-
69
- These must be installed in every consuming app:
70
-
71
- ```bash
72
- bun add hono zod
73
- ```
74
-
75
- | Package | Required version |
76
- |---|---|
77
- | `hono` | `>=4.12 <5` |
78
- | `zod` | `>=4.0 <5` |
79
-
80
- ### Optional
81
-
82
- Install only what your app actually uses:
83
-
84
- ```bash
85
- # MongoDB auth / sessions / cache
86
- bun add mongoose
87
-
88
- # Redis sessions, cache, rate limiting, or BullMQ
89
- bun add ioredis
90
-
91
- # Background job queues
92
- bun add bullmq
93
- ```
94
-
95
- | Package | Required version | When you need it |
96
- |---|---|---|
97
- | `mongoose` | `>=9.0 <10` | `db.auth: "mongo"`, `db.sessions: "mongo"`, or `db.cache: "mongo"` |
98
- | `ioredis` | `>=5.0 <6` | `db.redis: true` (the default), or any store set to `"redis"` |
99
- | `bullmq` | `>=5.0 <6` | Workers / queues |
100
-
101
- If you're running fully on SQLite or memory (no Redis, no MongoDB), none of the optional peers are needed.
102
-
103
- ---
104
-
105
- ## Quick Start
106
-
107
- ```ts
108
- // src/index.ts
109
- import { createServer } from "@lastshotlabs/bunshot";
110
- import { appConfig } from "@config/index";
111
-
112
- await createServer(appConfig);
113
- ```
114
-
115
- All configuration lives in `src/config/index.ts` — see the CLI-generated scaffold for the full setup.
116
-
117
- That's it. Your app gets:
118
-
119
- | Endpoint | Description |
120
- |---|---|
121
- | `POST /auth/register` | Create account, returns JWT |
122
- | `POST /auth/login` | Login, returns JWT (includes `emailVerified` when verification is configured) |
123
- | `POST /auth/logout` | Invalidates the current session only |
124
- | `GET /auth/me` | Returns current user's `userId`, `email`, `emailVerified`, and `googleLinked` (requires login) |
125
- | `POST /auth/set-password` | Set or update password (requires login) |
126
- | `GET /auth/sessions` | List active sessions with metadata — IP, user-agent, timestamps (requires login) |
127
- | `DELETE /auth/sessions/:sessionId` | Revoke a specific session by ID (requires login) |
128
- | `POST /auth/verify-email` | Verify email with token (when `emailVerification` is configured) |
129
- | `POST /auth/resend-verification` | Resend verification email (requires login, when `emailVerification` is configured) |
130
- | `POST /auth/forgot-password` | Request a password reset email (when `passwordReset` is configured) |
131
- | `POST /auth/reset-password` | Reset password using a token from the reset email (when `passwordReset` is configured) |
132
- | `GET /health` | Health check |
133
- | `GET /docs` | Scalar API docs UI |
134
- | `GET /openapi.json` | OpenAPI spec |
135
- | `WS /ws` | WebSocket endpoint (cookie-JWT auth) |
136
-
137
- ---
138
-
139
- ## Adding Routes
140
-
141
- Drop a file in your `routes/` directory. It must export a `router`:
142
-
143
- ```ts
144
- // src/routes/products.ts
145
- import { createRoute } from "@hono/zod-openapi";
146
- import { z } from "zod";
147
- import { createRouter, userAuth } from "@lastshotlabs/bunshot";
148
-
149
- export const router = createRouter();
150
-
151
- router.use("/products", userAuth); // require login
152
-
153
- router.openapi(
154
- createRoute({
155
- method: "get",
156
- path: "/products",
157
- responses: {
158
- 200: {
159
- content: { "application/json": { schema: z.object({ items: z.array(z.string()) }) } },
160
- description: "Product list",
161
- },
162
- },
163
- }),
164
- async (c) => {
165
- const userId = c.get("authUserId");
166
- return c.json({ items: [] }, 200);
167
- }
168
- );
169
- ```
170
-
171
- Routes are auto-discovered via glob — no registration needed. Subdirectories are supported, so you can organise by feature:
172
-
173
- ```
174
- routes/
175
- products.ts
176
- ingredients/
177
- list.ts
178
- detail.ts
179
- ```
180
-
181
- **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.
182
-
183
- ```ts
184
- // routes/tenants.ts — must match before generic routes
185
- export const priority = 1;
186
- export const router = createRouter();
187
- // ...
188
- ```
189
-
190
- ---
191
-
192
- ## MongoDB Connections
193
-
194
- MongoDB and Redis connect automatically inside `createServer` / `createApp`. Control the behavior via the `db` config object:
195
-
196
- ### Single database (default)
197
-
198
- Both auth and app data share one server. Uses `MONGO_*` env vars.
199
-
200
- ```ts
201
- await createServer({
202
- // ...
203
- db: { mongo: "single", redis: true }, // these are the defaults — can omit db entirely
204
- // app, auth, security are all optional with sensible defaults
205
- });
206
- ```
207
-
208
- ### Separate auth database
209
-
210
- 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.
211
-
212
- ```ts
213
- await createServer({
214
- // ...
215
- db: { mongo: "separate" },
216
- });
217
- ```
218
-
219
- ### Manual connections
220
-
221
- Set `mongo: false` and/or `redis: false` to skip auto-connect and manage connections yourself:
222
-
223
- ```ts
224
- import { connectAuthMongo, connectAppMongo, connectRedis, createServer } from "@lastshotlabs/bunshot";
225
-
226
- await connectAuthMongo();
227
- await connectAppMongo();
228
- await connectRedis();
229
-
230
- await createServer({
231
- // ...
232
- db: { mongo: false, redis: false },
233
- });
234
- ```
235
-
236
- `AuthUser` and all built-in auth routes always use `authConnection`. Your app models use `appConnection` (see Adding Models below).
237
-
238
- ---
239
-
240
- ## Adding Models
241
-
242
- 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.
243
-
244
- `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.
245
-
246
- ```ts
247
- // src/models/Product.ts
248
- import { appConnection } from "@lastshotlabs/bunshot";
249
- import { Schema } from "mongoose";
250
- import type { HydratedDocument } from "mongoose";
251
-
252
- interface IProduct {
253
- name: string;
254
- price: number;
255
- }
256
-
257
- export type ProductDocument = HydratedDocument<IProduct>;
258
-
259
- const ProductSchema = new Schema<IProduct>({
260
- name: { type: String, required: true },
261
- price: { type: Number, required: true },
262
- }, { timestamps: true });
263
-
264
- export const Product = appConnection.model<IProduct>("Product", ProductSchema);
265
- ```
266
-
267
- > **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.
268
-
269
- ---
270
-
271
- ## Jobs (BullMQ)
272
-
273
- > **Redis requirement**: BullMQ requires `maxmemory-policy noeviction`. Set it in `redis.conf` or via Docker:
274
- > ```yaml
275
- > command: redis-server --maxmemory-policy noeviction
276
- > ```
277
-
278
- Queues and workers share the existing Redis connection automatically.
279
-
280
- ### Define a queue
281
-
282
- ```ts
283
- // src/queues/email.ts
284
- import { createQueue } from "@lastshotlabs/bunshot";
285
-
286
- export type EmailJob = { to: string; subject: string; body: string };
287
-
288
- export const emailQueue = createQueue<EmailJob>("email");
289
- ```
290
-
291
- ### Add jobs
292
-
293
- ```ts
294
- import { emailQueue } from "../queues/email";
295
-
296
- await emailQueue.add("send-welcome", { to: "user@example.com", subject: "Welcome", body: "..." });
297
-
298
- // with options
299
- await emailQueue.add("send-reset", payload, { delay: 5000, attempts: 3 });
300
- ```
301
-
302
- ### Define a worker
303
-
304
- ```ts
305
- // src/workers/email.ts
306
- import { createWorker } from "@lastshotlabs/bunshot";
307
- import type { EmailJob } from "../queues/email";
308
-
309
- export const emailWorker = createWorker<EmailJob>("email", async (job) => {
310
- const { to, subject, body } = job.data;
311
- // send email...
312
- });
313
- ```
314
-
315
- Workers in `workersDir` are auto-discovered and registered after the server starts — no manual imports needed. Subdirectories are supported.
316
-
317
- ### Broadcasting WebSocket messages from a worker
318
-
319
- Use `publish` to broadcast to all connected clients from inside a worker (or anywhere):
320
-
321
- ```ts
322
- // src/workers/notify.ts
323
- import { createWorker, publish } from "@lastshotlabs/bunshot";
324
- import type { NotifyJob } from "../queues/notify";
325
-
326
- export const notifyWorker = createWorker<NotifyJob>("notify", async (job) => {
327
- const { text, from } = job.data;
328
- publish("broadcast", { text, from, timestamp: new Date().toISOString() });
329
- });
330
- ```
331
-
332
- `publish` is available after `createServer` resolves. Workers are loaded after that point, so it's always safe to use inside a worker.
333
-
334
- ---
335
-
336
- ## WebSocket
337
-
338
- The `/ws` endpoint is mounted automatically by `createServer`. No extra setup needed.
339
-
340
- ### Default behaviour
341
-
342
- | What | Default |
343
- |---|---|
344
- | Upgrade / auth | Reads `auth-token` cookie → verifies JWT → checks session → sets `ws.data.userId` |
345
- | `open` | Logs connection, sends `{ event: "connected", id }` |
346
- | `message` | Handles room actions (see below), echoes everything else |
347
- | `close` | Clears `ws.data.rooms`, logs disconnection |
348
-
349
- ### Socket data (`SocketData`)
350
-
351
- `SocketData` is generic pass a type parameter to add your own fields:
352
-
353
- ```ts
354
- type SocketData<T extends object = object> = {
355
- id: string; // unique connection ID (UUID)
356
- userId: string | null; // null if unauthenticated
357
- rooms: Set<string>; // rooms this socket is subscribed to
358
- } & T;
359
- ```
360
-
361
- **Extending with custom fields:**
362
-
363
- ```ts
364
- import { createServer, type SocketData } from "@lastshotlabs/bunshot";
365
-
366
- type MyData = { tenantId: string; role: "admin" | "user" };
367
-
368
- await createServer<MyData>({
369
- ws: {
370
- upgradeHandler: async (req, server) => {
371
- const tenantId = req.headers.get("x-tenant-id") ?? "default";
372
- const upgraded = server.upgrade(req, {
373
- data: { id: crypto.randomUUID(), userId: null, rooms: new Set(), tenantId, role: "user" },
374
- });
375
- return upgraded ? undefined : Response.json({ error: "Upgrade failed" }, { status: 400 });
376
- },
377
- handler: {
378
- open(ws) {
379
- // ws.data.tenantId and ws.data.role are fully typed
380
- console.log(ws.data.tenantId, ws.data.role);
381
- },
382
- },
383
- onRoomSubscribe(ws, room) {
384
- return ws.data.role === "admin" || !room.startsWith("admin:");
385
- },
386
- },
387
- });
388
- ```
389
-
390
- With no type parameter, `SocketData` defaults to `{ id, userId, rooms }` — the base shape used by the default upgrade handler.
391
-
392
- ### Overriding the message handler
393
-
394
- Pass `ws.handler` to `createServer` to replace the default echo. Room action handling always runs first — your handler only receives non-room messages:
395
-
396
- ```ts
397
- await createServer({
398
- ws: {
399
- handler: {
400
- open(ws) {
401
- ws.send(JSON.stringify({ event: "connected", id: ws.data.id }));
402
- },
403
- message(ws, message) {
404
- // room subscribe/unsubscribe already handled — put your logic here
405
- const parsed = JSON.parse(message as string);
406
- if (parsed.action === "ping") ws.send(JSON.stringify({ event: "pong" }));
407
- },
408
- close(ws, code, reason) {
409
- // ws.data.rooms already cleared
410
- },
411
- },
412
- },
413
- });
414
- ```
415
-
416
- You can supply any subset of `open`, `message`, `close`, `drain` — unset handlers fall back to the defaults.
417
-
418
- ### Overriding the upgrade / auth handler
419
-
420
- Replace the default cookie-JWT handshake entirely via `ws.upgradeHandler`. You must call `server.upgrade()` yourself and include `rooms: new Set()` in data:
421
-
422
- ```ts
423
- await createServer({
424
- ws: {
425
- upgradeHandler: async (req, server) => {
426
- const token = req.headers.get("x-my-token");
427
- const userId = token ? await verifyMyToken(token) : null;
428
- const upgraded = server.upgrade(req, {
429
- data: { id: crypto.randomUUID(), userId, rooms: new Set() },
430
- });
431
- return upgraded ? undefined : Response.json({ error: "Upgrade failed" }, { status: 400 });
432
- },
433
- },
434
- });
435
- ```
436
-
437
- ---
438
-
439
- ## WebSocket Rooms / Channels
440
-
441
- 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`.
442
-
443
- ### WS utilities
444
-
445
- | Export | Description |
446
- |---|---|
447
- | `publish(room, data)` | Broadcast `data` to all sockets subscribed to `room` |
448
- | `subscribe(ws, room)` | Subscribe a socket to a room and track it in `ws.data.rooms` |
449
- | `unsubscribe(ws, room)` | Unsubscribe a socket from a room |
450
- | `getSubscriptions(ws)` | Returns `string[]` of rooms the socket is currently in |
451
- | `getRooms()` | Returns `string[]` of all rooms with at least one active subscriber |
452
- | `getRoomSubscribers(room)` | Returns `string[]` of socket IDs currently subscribed to `room` |
453
- | `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. |
454
-
455
- ### Client → server: join or leave a room
456
-
457
- Send a JSON message with `action: "subscribe"` or `action: "unsubscribe"`:
458
-
459
- ```ts
460
- ws.send(JSON.stringify({ action: "subscribe", room: "chat:general" }));
461
- ws.send(JSON.stringify({ action: "unsubscribe", room: "chat:general" }));
462
- ```
463
-
464
- Server responses:
465
-
466
- | Event | Meaning |
467
- |---|---|
468
- | `{ event: "subscribed", room }` | Successfully joined |
469
- | `{ event: "unsubscribed", room }` | Successfully left |
470
- | `{ event: "subscribe_denied", room }` | Blocked by `onRoomSubscribe` guard |
471
-
472
- Any non-room message is passed through to your `websocket.message` handler unchanged.
473
-
474
- ### Server room: broadcast
475
-
476
- ```ts
477
- import { publish } from "@lastshotlabs/bunshot";
478
-
479
- publish("chat:general", { text: "Hello room!", from: "system" });
480
- ```
481
-
482
- All sockets subscribed to `"chat:general"` receive the message. Works from anywhere — routes, workers, anywhere after `createServer` resolves.
483
-
484
- ### Server-side: manage subscriptions in code
485
-
486
- Use `subscribe` / `unsubscribe` anywhere you have a `ws` reference (e.g. in `ws.handler.open` to auto-join personal rooms):
487
-
488
- ```ts
489
- import { subscribe, unsubscribe, getSubscriptions } from "@lastshotlabs/bunshot";
490
-
491
- await createServer({
492
- ws: {
493
- handler: {
494
- open(ws) {
495
- // auto-subscribe authenticated users to their personal room
496
- if (ws.data.userId) subscribe(ws, `user:${ws.data.userId}`);
497
- },
498
- message(ws, message) {
499
- // handleRoomActions already ran — only non-room messages reach here
500
- const rooms = getSubscriptions(ws); // current room list
501
- },
502
- close(ws) {
503
- // ws.data.rooms is cleared automatically — no cleanup needed
504
- },
505
- },
506
- },
507
- });
508
- ```
509
-
510
- ### Room permission guard
511
-
512
- 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.
513
-
514
- ```ts
515
- await createServer({
516
- ws: {
517
- onRoomSubscribe(ws, room) {
518
- if (!ws.data.userId) return false; // must be logged in
519
- if (room.startsWith("admin:")) return isAdmin(ws.data.userId); // role check
520
- if (room.startsWith("user:")) return room === `user:${ws.data.userId}`; // ownership
521
- return true;
522
- },
523
- },
524
- });
525
-
526
- // async guard query DB or cache
527
- await createServer({
528
- ws: {
529
- onRoomSubscribe: async (ws, room) => {
530
- const ok = await db.roomMembers.findOne({ room, userId: ws.data.userId });
531
- return !!ok;
532
- },
533
- },
534
- });
535
- ```
536
-
537
- ---
538
-
539
- ## Adding Middleware
540
-
541
- ### Global (runs on every request)
542
-
543
- Pass via `middleware` config — injected after `identify`, before route matching:
544
-
545
- ```ts
546
- await createServer({
547
- routesDir: import.meta.dir + "/routes",
548
- app: { name: "My App", version: "1.0.0" },
549
- middleware: [myMiddleware],
550
- });
551
- ```
552
-
553
- Write it using core's exported types:
554
-
555
- ```ts
556
- // src/middleware/tenant.ts
557
- import type { MiddlewareHandler } from "hono";
558
- import type { AppEnv } from "@lastshotlabs/bunshot";
559
-
560
- export const tenantMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
561
- // c.get("userId") is available — identify has already run
562
- await next();
563
- };
564
- ```
565
-
566
- ### Per-route
567
-
568
- ```ts
569
- import { userAuth, rateLimit } from "@lastshotlabs/bunshot";
570
-
571
- router.use("/admin", userAuth);
572
- router.use("/admin", rateLimit({ windowMs: 60_000, max: 10 }));
573
- ```
574
-
575
- ---
576
-
577
- ## Response Caching
578
-
579
- 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.
580
-
581
- ### Basic usage
582
-
583
- ```ts
584
- import { cacheResponse, bustCache } from "@lastshotlabs/bunshot";
585
-
586
- // GET cache the response for 60 seconds in Redis (default)
587
- router.use("/products", cacheResponse({ ttl: 60, key: "products" }));
588
-
589
- // indefinite — cached until busted
590
- router.use("/config", cacheResponse({ key: "config" }));
591
-
592
- router.get("/products", async (c) => {
593
- const items = await Product.find();
594
- return c.json({ items });
595
- });
596
-
597
- // POST — write data, then bust the shared key (hits all connected stores)
598
- router.post("/products", userAuth, async (c) => {
599
- const body = await c.req.json();
600
- await Product.create(body);
601
- await bustCache("products");
602
- return c.json({ ok: true }, 201);
603
- });
604
- ```
605
-
606
- 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.
607
-
608
- ### Choosing a cache store
609
-
610
- Pass `store` to select where the response is cached. Defaults to `"redis"`.
611
-
612
- ```ts
613
- // Redis (default)
614
- cacheResponse({ key: "products", ttl: 60 })
615
-
616
- // MongoDB — uses appConnection, stores in the `cache_entries` collection
617
- // TTL is handled natively via a MongoDB expiry index on the expiresAt field
618
- cacheResponse({ key: "products", ttl: 300, store: "mongo" })
619
-
620
- // SQLite — uses the same .db file as sqliteAuthAdapter; requires setSqliteDb or sqliteDb config
621
- cacheResponse({ key: "products", ttl: 60, store: "sqlite" })
622
-
623
- // Memory — in-process Map, ephemeral (cleared on restart), no external dependencies
624
- cacheResponse({ key: "products", ttl: 60, store: "memory" })
625
- ```
626
-
627
- 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.
628
-
629
- **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.
630
-
631
- ### Busting cached entries
632
-
633
- `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:
634
-
635
- ```ts
636
- await bustCache("products"); // hits whichever stores are connected
637
- ```
638
-
639
- ### Per-user caching
640
-
641
- The `key` function receives the full Hono context, so you can scope cache entries to the authenticated user:
642
-
643
- ```ts
644
- router.use("/feed", userAuth, cacheResponse({
645
- ttl: 60,
646
- key: (c) => `feed:${c.get("authUserId")}`,
647
- }));
648
- ```
649
-
650
- `authUserId` is populated by `identify`, which always runs before route middleware, so it's safe to use here.
651
-
652
- ### Per-resource caching
653
-
654
- For routes with dynamic segments, use the function form of `key`. Produce the same string in `bustCache`:
655
-
656
- ```ts
657
- // GET /products/:id
658
- router.use("/products/:id", cacheResponse({
659
- ttl: 60,
660
- key: (c) => `product:${c.req.param("id")}`,
661
- }));
662
-
663
- router.get("/products/:id", async (c) => {
664
- const item = await Product.findById(c.req.param("id"));
665
- return c.json(item);
666
- });
667
-
668
- // PUT /products/:id
669
- router.put("/products/:id", userAuth, async (c) => {
670
- const id = c.req.param("id");
671
- await Product.findByIdAndUpdate(id, await c.req.json());
672
- await bustCache(`product:${id}`);
673
- return c.json({ ok: true });
674
- });
675
- ```
676
-
677
- 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`.
678
-
679
- ### Busting by pattern
680
-
681
- 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:
682
-
683
- ```ts
684
- import { bustCachePattern } from "@lastshotlabs/bunshot";
685
-
686
- // key includes query params: `balance:${userId}:${from}:${to}:${groupBy}`
687
- // bust all balance entries for this user regardless of params
688
- await bustCachePattern(`balance:${userId}:*`);
689
- ```
690
-
691
- 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.
692
-
693
- ---
694
-
695
- ## Extending the Context (Custom Variables)
696
-
697
- 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.
698
-
699
- ```ts
700
- // src/lib/context.ts
701
- import { createRouter as coreCreateRouter, type AppEnv } from "@lastshotlabs/bunshot";
702
- import type { OpenAPIHono } from "@hono/zod-openapi";
703
-
704
- export type MyVariables = AppEnv["Variables"] & {
705
- tenantId: string;
706
- };
707
-
708
- export type MyEnv = { Variables: MyVariables };
709
-
710
- export const createRouter = () => coreCreateRouter() as unknown as OpenAPIHono<MyEnv>;
711
- ```
712
-
713
- Use the local `createRouter` instead of the one from the package — your routes will then have full TypeScript access to the extra variables:
714
-
715
- ```ts
716
- // src/routes/items.ts
717
- import { createRouter } from "../lib/context";
718
- import { userAuth } from "@lastshotlabs/bunshot";
719
-
720
- export const router = createRouter();
721
-
722
- router.use("/items", userAuth);
723
-
724
- router.get("/items", async (c) => {
725
- const tenantId = c.get("tenantId"); // fully typed
726
- const userId = c.get("userId"); // still available from AppEnv
727
- return c.json({ tenantId, userId });
728
- });
729
- ```
730
-
731
- Populate the extra variables from a global middleware:
732
-
733
- ```ts
734
- // src/middleware/tenant.ts
735
- import type { MiddlewareHandler } from "hono";
736
- import type { MyEnv } from "../lib/context";
737
-
738
- export const tenantMiddleware: MiddlewareHandler<MyEnv> = async (c, next) => {
739
- const tenantId = c.req.header("x-tenant-id") ?? "default";
740
- c.set("tenantId", tenantId);
741
- await next();
742
- };
743
- ```
744
-
745
- Then register it in `createServer`:
746
-
747
- ```ts
748
- await createServer({
749
- routesDir: import.meta.dir + "/routes",
750
- app: { name: "My App", version: "1.0.0" },
751
- middleware: [tenantMiddleware],
752
- });
753
- ```
754
-
755
- ---
756
-
757
- ## Configuration
758
-
759
- ```ts
760
- await createServer({
761
- // Required
762
- routesDir: import.meta.dir + "/routes",
763
-
764
- // App metadata (shown in root endpoint + OpenAPI docs)
765
- app: {
766
- name: "My App", // default: "Bun Core API"
767
- version: "1.0.0", // default: "1.0.0"
768
- },
769
-
770
- // Auth, roles, and OAuth
771
- auth: {
772
- enabled: true, // default: trueset false to disable /auth/* routes
773
- adapter: pgAuthAdapter, // custom adapter — overrides db.auth (use for Postgres etc.)
774
- roles: ["admin", "editor", "user"], // valid roles — required to use requireRole
775
- defaultRole: "user", // assigned to every new user on /auth/register
776
- primaryField: "email", // default: "email" — use "username" or "phone" to change the login identifier
777
- emailVerification: { // optional — only active when primaryField is "email"
778
- required: true, // default: false (soft gate) — set true to block login until verified
779
- tokenExpiry: 60 * 60, // default: 86400 (24 hours) — token TTL in seconds
780
- onSend: async (email, token) => { // called after registration and resend — use any email provider
781
- await resend.emails.send({ to: email, subject: "Verify your email", text: `Token: ${token}` });
782
- },
783
- },
784
- passwordReset: { // optional only active when primaryField is "email"
785
- tokenExpiry: 60 * 60, // default: 3600 (1 hour) — token TTL in seconds
786
- onSend: async (email, token) => { // called by POST /auth/forgot-password — use any email provider
787
- await resend.emails.send({ to: email, subject: "Reset your password", text: `Token: ${token}` });
788
- },
789
- },
790
- rateLimit: { // optional — built-in auth endpoint rate limiting
791
- login: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 failures / 15 min
792
- register: { windowMs: 60 * 60 * 1000, max: 5 }, // default: 5 attempts / hour (per IP)
793
- verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
794
- resendVerification: { windowMs: 60 * 60 * 1000, max: 3 }, // default: 3 attempts / hour (per user)
795
- forgotPassword: { windowMs: 15 * 60 * 1000, max: 5 }, // default: 5 attempts / 15 min (per IP)
796
- resetPassword: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
797
- store: "redis", // default: "redis" when Redis is enabled, else "memory"
798
- },
799
- sessionPolicy: { // optional — session concurrency and metadata
800
- maxSessions: 6, // default: 6 — max simultaneous sessions per user; oldest evicted when exceeded
801
- persistSessionMetadata: true, // default: true — keep IP/UA/timestamp row after session expires (for device detection)
802
- includeInactiveSessions: false, // default: false — include expired/deleted sessions in GET /auth/sessions
803
- trackLastActive: false, // default: false — update lastActiveAt on every auth'd request (adds one DB write)
804
- },
805
- oauth: {
806
- providers: { google: { ... }, apple: { ... } }, // omit a provider to disable it
807
- postRedirect: "/dashboard", // default: "/"
808
- },
809
- },
810
-
811
- // Security
812
- security: {
813
- cors: ["https://myapp.com"], // default: "*"
814
- rateLimit: { windowMs: 60_000, max: 100 }, // default: 100 req/min
815
- bearerAuth: true, // default: true — set false to disable, or { bypass: ["/my-public-route"] }
816
- botProtection: {
817
- fingerprintRateLimit: true, // rate-limit by HTTP fingerprint (IP-rotation resistant). default: false
818
- blockList: ["198.51.100.0/24"], // IPv4 CIDRs or exact IPs to block with 403. default: []
819
- },
820
- },
821
-
822
- // Extra middleware injected after identify, before route matching
823
- middleware: [],
824
-
825
- // Connections & store routing (all optional shown with defaults)
826
- db: {
827
- mongo: "single", // "single" | "separate" | false
828
- redis: true, // false to skip auto-connect
829
- sqlite: undefined, // absolute path to .db file required when any store is "sqlite"
830
- auth: "mongo", // "mongo" | "sqlite" | "memory" which built-in auth adapter to use
831
- sessions: "redis", // "redis" | "mongo" | "sqlite" | "memory"
832
- oauthState: "redis", // default: follows sessions
833
- cache: "redis", // global default for cacheResponse (overridable per-route)
834
- },
835
-
836
- // Server
837
- port: 3000, // default: process.env.PORT ?? 3000
838
- workersDir: import.meta.dir + "/workers", // auto-imports all .ts files after server starts
839
- enableWorkers: true, // default: true — set false to disable auto-loading
840
-
841
- // WebSocket (see WebSocket section for full examples)
842
- ws: {
843
- handler: { ... }, // override open/message/close/drain handlers
844
- upgradeHandler: async (req, server) => { ... }, // replace default cookie-JWT upgrade logic
845
- onRoomSubscribe(ws, room) { return true; }, // gate room subscriptions; can be async
846
- },
847
- });
848
- ```
849
-
850
- ---
851
-
852
- ## Running without Redis
853
-
854
- 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.
855
-
856
- ```ts
857
- await createServer({
858
- db: {
859
- mongo: "single",
860
- redis: false,
861
- sessions: "mongo", // sessions + OAuth state → MongoDB
862
- cache: "mongo", // or omit cacheResponse entirely if not using it
863
- },
864
- });
865
- ```
866
-
867
- 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.
868
-
869
- ---
870
-
871
- ## Running without Redis or MongoDB
872
-
873
- Two lightweight options for local dev, tests, or small projects with no external services:
874
-
875
- ### SQLite — persisted to disk
876
-
877
- Uses `bun:sqlite` (built into Bun, zero npm deps). A single `.db` file holds all users, sessions, OAuth state, and cache.
878
-
879
- ```ts
880
- await createServer({
881
- routesDir: import.meta.dir + "/routes",
882
- app: { name: "My App", version: "1.0.0" },
883
- db: {
884
- auth: "sqlite",
885
- sqlite: import.meta.dir + "/../data.db", // created automatically on first run
886
- mongo: false,
887
- redis: false,
888
- sessions: "sqlite",
889
- cache: "sqlite",
890
- },
891
- });
892
- ```
893
-
894
- #### Optional: periodic cleanup of expired rows
895
-
896
- Expired rows are filtered out lazily on read. For long-running servers, sweep them periodically:
897
-
898
- ```ts
899
- import { startSqliteCleanup } from "@lastshotlabs/bunshot";
900
-
901
- startSqliteCleanup(); // default: every hour
902
- startSqliteCleanup(5 * 60_000); // custom interval (ms)
903
- ```
904
-
905
- ### Memory — ephemeral, great for tests
906
-
907
- Pure in-memory Maps. No files, no external services. All state is lost on process restart.
908
-
909
- ```ts
910
- import { createServer, clearMemoryStore } from "@lastshotlabs/bunshot";
911
-
912
- await createServer({
913
- routesDir: import.meta.dir + "/routes",
914
- app: { name: "My App", version: "1.0.0" },
915
- db: {
916
- auth: "memory",
917
- mongo: false,
918
- redis: false,
919
- sessions: "memory",
920
- cache: "memory",
921
- },
922
- });
923
-
924
- // In tests — reset all state between test cases:
925
- clearMemoryStore();
926
- ```
927
-
928
- ### Limitations (both sqlite and memory)
929
-
930
- - BullMQ queues still require Redis
931
-
932
- ---
933
-
934
- ## Auth Flow
935
-
936
- 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).
937
-
938
- ### Browser clients
939
- 1. `POST /auth/login` JWT set as HttpOnly cookie automatically
940
- 2. All subsequent requests send the cookie — no extra code needed
941
-
942
- ### API / non-browser clients
943
- 1. `POST /auth/login` → read `token` from response body
944
- 2. Send `x-user-token: <token>` header on every request
945
-
946
- ### Session management
947
-
948
- 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.
949
-
950
- ```
951
- GET /auth/sessions → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
952
- DELETE /auth/sessions/:sessionId → revoke a specific session (other sessions unaffected)
953
- POST /auth/logout → revoke only the current session
954
- ```
955
-
956
- 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.
957
-
958
- 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.
959
-
960
- ### Protecting routes
961
-
962
- ```ts
963
- import { userAuth, requireRole, requireVerifiedEmail } from "@lastshotlabs/bunshot";
964
-
965
- router.use("/my-route", userAuth); // returns 401 if not logged in
966
- router.use("/admin", userAuth, requireRole("admin")); // returns 403 if user lacks role
967
- router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
968
- router.use("/dashboard", userAuth, requireVerifiedEmail); // returns 403 if email not verified
969
- ```
970
-
971
- ### Custom auth adapter
972
-
973
- 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"`).
974
-
975
- The schema should include a `roles` column if you plan to use role-based access:
976
-
977
- ```sql
978
- -- roles stored as a text array in Postgres
979
- ALTER TABLE users ADD COLUMN roles text[] NOT NULL DEFAULT '{}';
980
- ```
981
-
982
- ```ts
983
- import type { AuthAdapter } from "@lastshotlabs/bunshot";
984
- import { HttpError } from "@lastshotlabs/bunshot";
985
- import { db } from "./db";
986
- import { users } from "./schema";
987
- import { eq, sql } from "drizzle-orm";
988
-
989
- const pgAuthAdapter: AuthAdapter = {
990
- async findByEmail(email) {
991
- const user = await db.query.users.findFirst({ where: eq(users.email, email) });
992
- return user ? { id: user.id, passwordHash: user.passwordHash } : null;
993
- },
994
- async create(email, passwordHash) {
995
- try {
996
- const [user] = await db.insert(users).values({ email, passwordHash }).returning({ id: users.id });
997
- return { id: user.id };
998
- } catch (err: any) {
999
- if (/* unique constraint */ err.code === "23505") throw new HttpError(409, "Email already registered");
1000
- throw err;
1001
- }
1002
- },
1003
- // --- Role methods (optional — only needed if using roles / requireRole) ---
1004
- async getRoles(userId) {
1005
- const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
1006
- return user?.roles ?? [];
1007
- },
1008
- async setRoles(userId, roles) { // required if using defaultRole
1009
- await db.update(users).set({ roles }).where(eq(users.id, userId));
1010
- },
1011
- async addRole(userId, role) {
1012
- await db.update(users)
1013
- .set({ roles: sql`array_append(roles, ${role})` })
1014
- .where(eq(users.id, userId));
1015
- },
1016
- async removeRole(userId, role) {
1017
- await db.update(users)
1018
- .set({ roles: sql`array_remove(roles, ${role})` })
1019
- .where(eq(users.id, userId));
1020
- },
1021
- };
1022
-
1023
- await createServer({
1024
- routesDir: import.meta.dir + "/routes",
1025
- app: { name: "My App", version: "1.0.0" },
1026
- auth: {
1027
- roles: ["admin", "editor", "user"],
1028
- defaultRole: "user",
1029
- adapter: pgAuthAdapter,
1030
- },
1031
- });
1032
- ```
1033
-
1034
- The adapter is responsible for:
1035
- - `findByEmail` — return `{ id, passwordHash }` or `null` if not found
1036
- - `create` — insert the user and return `{ id }`, throw `HttpError(409, ...)` on duplicate email
1037
- - `setPassword` _(optional)_ — update the stored password hash for `userId`; implement to enable `POST /auth/set-password`
1038
- - `findOrCreateByProvider` _(optional)_ required for OAuth social login
1039
- - `linkProvider` _(optional)_ — add a provider identity to an existing user; implement to enable `GET /auth/{provider}/link`
1040
- - `unlinkProvider` _(optional)_ — remove all identities for a provider from a user; implement to enable `DELETE /auth/{provider}/link`
1041
- - `getRoles` _(optional)_ return the roles assigned to `userId`; implement to enable `requireRole` middleware
1042
- - `setRoles` _(optional)_ — replace all roles; required if using `defaultRole`
1043
- - `addRole` _(optional)_ — add a single role; implement to use `addUserRole`
1044
- - `removeRole` _(optional)_ — remove a single role; implement to use `removeUserRole`
1045
- - `getUser` _(optional)_ — return `{ email?, providerIds?, emailVerified? }` for `userId`; implement to populate `GET /auth/me` (including `googleLinked` and `emailVerified`)
1046
- - `findByIdentifier` _(optional)_ — look up a user by the configured `primaryField` value; implement for non-email primary fields. Falls back to `findByEmail` if absent.
1047
- - `setEmailVerified` _(optional)_ mark a user as email-verified; implement to support `POST /auth/verify-email`
1048
- - `getEmailVerified` _(optional)_ — return whether a user is email-verified; implement to support the `emailVerification.required` gate and `POST /auth/resend-verification`
1049
-
1050
- Everything else (password hashing, JWT signing, Redis sessions) is handled by the package.
1051
-
1052
- ### Auth Rate Limiting
1053
-
1054
- All built-in auth endpoints are rate-limited out of the box with sensible defaults. No configuration needed — just be aware of the behavior:
1055
-
1056
- | Endpoint | Key | Counts | Default limit |
1057
- |---|---|---|---|
1058
- | `POST /auth/login` | identifier (email/username/phone) | **Failures only** — reset on success | 10 failures / 15 min |
1059
- | `POST /auth/register` | IP address | Every attempt | 5 / hour |
1060
- | `POST /auth/verify-email` | IP address | Every attempt | 10 / 15 min |
1061
- | `POST /auth/resend-verification` | User ID (authenticated) | Every attempt | 3 / hour |
1062
- | `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
1063
- | `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
1064
-
1065
- 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.
1066
-
1067
- #### Tuning limits
1068
-
1069
- ```ts
1070
- await createServer({
1071
- auth: {
1072
- rateLimit: {
1073
- login: { windowMs: 10 * 60 * 1000, max: 5 }, // stricter: 5 failures / 10 min
1074
- register: { windowMs: 60 * 60 * 1000, max: 3 },
1075
- verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // leave at default
1076
- resendVerification: { windowMs: 60 * 60 * 1000, max: 2 },
1077
- store: "redis", // default when Redis is enabled — shared across all server instances
1078
- },
1079
- },
1080
- });
1081
- ```
1082
-
1083
- #### Manually clearing a limit (admin unlock)
1084
-
1085
- If a legitimate user gets locked out, call `bustAuthLimit` with the same key format the limiter uses:
1086
-
1087
- ```ts
1088
- import { bustAuthLimit } from "@lastshotlabs/bunshot";
1089
-
1090
- // Admin route: POST /admin/unblock-login
1091
- router.post("/admin/unblock-login", userAuth, requireRole("admin"), async (c) => {
1092
- const { identifier } = await c.req.json();
1093
- await bustAuthLimit(`login:${identifier}`);
1094
- return c.json({ message: "Login limit cleared" });
1095
- });
1096
- ```
1097
-
1098
- Key formats: `login:{identifier}`, `register:{ip}`, `verify:{ip}`, `resend:{userId}`.
1099
-
1100
- #### Using the rate limiter in your own routes
1101
-
1102
- `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`.
1103
-
1104
- ```ts
1105
- import { trackAttempt, isLimited, bustAuthLimit } from "@lastshotlabs/bunshot";
1106
-
1107
- // trackAttempt — increments the counter and returns true if now over the limit
1108
- // isLimited — checks without incrementing (read-only)
1109
- // bustAuthLimit resets a key (e.g. on success or admin unlock)
1110
-
1111
- router.post("/api/submit", async (c) => {
1112
- const ip = c.req.header("x-forwarded-for") ?? "unknown";
1113
- const key = `submit:${ip}`;
1114
-
1115
- if (await trackAttempt(key, { windowMs: 60 * 1000, max: 5 })) {
1116
- return c.json({ error: "Too many requests" }, 429);
1117
- }
1118
-
1119
- // ... handle request
1120
- return c.json({ ok: true });
1121
- });
1122
- ```
1123
-
1124
- 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:
1125
-
1126
- ```ts
1127
- if (await isLimited(key, opts)) {
1128
- return c.json({ error: "Too many requests" }, 429);
1129
- }
1130
- ```
1131
-
1132
- 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.
1133
-
1134
- #### Store
1135
-
1136
- 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.
1137
-
1138
- ---
1139
-
1140
- ### Bot Protection
1141
-
1142
- 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.
1143
-
1144
- #### Fingerprint rate limiting
1145
-
1146
- 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.).
1147
-
1148
- 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.
1149
-
1150
- ```ts
1151
- await createServer({
1152
- security: {
1153
- rateLimit: { windowMs: 60_000, max: 100 }, // applies to both IP and fingerprint buckets
1154
- botProtection: {
1155
- fingerprintRateLimit: true,
1156
- },
1157
- },
1158
- });
1159
- ```
1160
-
1161
- 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).
1162
-
1163
- #### IP / CIDR blocklist
1164
-
1165
- 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.
1166
-
1167
- ```ts
1168
- await createServer({
1169
- security: {
1170
- botProtection: {
1171
- blockList: [
1172
- "198.51.100.0/24", // IPv4 CIDR
1173
- "203.0.113.42", // exact IPv4
1174
- "2001:db8::1", // exact IPv6
1175
- ],
1176
- },
1177
- },
1178
- });
1179
- ```
1180
-
1181
- Both options can be combined. The middleware order is: blocklist → IP rate limit → fingerprint rate limit.
1182
-
1183
- #### Apply `botProtection` to individual routes
1184
-
1185
- `botProtection` is also exported for per-route use:
1186
-
1187
- ```ts
1188
- import { botProtection } from "@lastshotlabs/bunshot";
1189
-
1190
- router.use("/api/submit", botProtection({ blockList: ["198.51.100.0/24"] }));
1191
- ```
1192
-
1193
- ---
1194
-
1195
- ### Setting a password after social login
1196
-
1197
- 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`:
1198
-
1199
- ```ts
1200
- // Client (logged-in user)
1201
- await fetch("/auth/set-password", {
1202
- method: "POST",
1203
- headers: { "Content-Type": "application/json", "x-user-token": token },
1204
- body: JSON.stringify({ password: "mynewpassword" }),
1205
- });
1206
- ```
1207
-
1208
- 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`.
1209
-
1210
- To support it with a custom adapter:
1211
-
1212
- ```ts
1213
- const myAdapter: AuthAdapter = {
1214
- findByEmail: ...,
1215
- create: ...,
1216
- async setPassword(userId, passwordHash) {
1217
- await db.update(users).set({ passwordHash }).where(eq(users.id, userId));
1218
- },
1219
- };
1220
- ```
1221
-
1222
- ---
1223
-
1224
- ## Roles
1225
-
1226
- ### Setup
1227
-
1228
- Declare the valid roles for your app in `createServer` / `createApp`:
1229
-
1230
- ```ts
1231
- await createServer({
1232
- auth: {
1233
- roles: ["admin", "editor", "user"],
1234
- defaultRole: "user", // automatically assigned on /auth/register
1235
- },
1236
- // ...
1237
- });
1238
- ```
1239
-
1240
- `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.
1241
-
1242
- ### Assigning roles to a user
1243
-
1244
- Three helpers are available depending on what you need:
1245
-
1246
- | Helper | Behaviour |
1247
- |---|---|
1248
- | `setUserRoles(userId, roles)` | Replace all roles — pass the full desired set |
1249
- | `addUserRole(userId, role)` | Add a single role, leaving others unchanged |
1250
- | `removeUserRole(userId, role)` | Remove a single role, leaving others unchanged |
1251
-
1252
- ```ts
1253
- import { setUserRoles, addUserRole, removeUserRole, userAuth, requireRole } from "@lastshotlabs/bunshot";
1254
-
1255
- // promote a user to admin
1256
- router.post("/admin/users/:id/promote", userAuth, requireRole("admin"), async (c) => {
1257
- await addUserRole(c.req.param("id"), "admin");
1258
- return c.json({ ok: true });
1259
- });
1260
-
1261
- // revoke a role
1262
- router.post("/admin/users/:id/demote", userAuth, requireRole("admin"), async (c) => {
1263
- await removeUserRole(c.req.param("id"), "admin");
1264
- return c.json({ ok: true });
1265
- });
1266
-
1267
- // replace all roles at once
1268
- router.put("/admin/users/:id/roles", userAuth, requireRole("admin"), async (c) => {
1269
- const { roles } = await c.req.json();
1270
- await setUserRoles(c.req.param("id"), roles);
1271
- return c.json({ ok: true });
1272
- });
1273
- ```
1274
-
1275
- ### Protecting routes by role
1276
-
1277
- `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.
1278
-
1279
- ```ts
1280
- import { userAuth, requireRole } from "@lastshotlabs/bunshot";
1281
-
1282
- router.use("/admin", userAuth, requireRole("admin"));
1283
- router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
1284
- ```
1285
-
1286
- | Scenario | Response |
1287
- |---|---|
1288
- | No session | `401 Unauthorized` |
1289
- | Authenticated, wrong role | `403 Forbidden` |
1290
- | Authenticated, correct role | passes through |
1291
-
1292
- ### Custom adapter with roles
1293
-
1294
- If you're using a custom `authAdapter`, implement the role methods to back role operations with your own store:
1295
-
1296
- | Method | Required for |
1297
- |---|---|
1298
- | `getRoles(userId)` | `requireRole` middleware |
1299
- | `setRoles(userId, roles)` | `defaultRole` assignment on registration, full replace |
1300
- | `addRole(userId, role)` | Granular role addition |
1301
- | `removeRole(userId, role)` | Granular role removal |
1302
-
1303
- 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.
1304
-
1305
- ```ts
1306
- const myAdapter: AuthAdapter = {
1307
- findByEmail: ...,
1308
- create: ...,
1309
- async getRoles(userId) {
1310
- const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
1311
- return user?.roles ?? [];
1312
- },
1313
- async setRoles(userId, roles) {
1314
- await db.update(users).set({ roles }).where(eq(users.id, userId));
1315
- },
1316
- async addRole(userId, role) {
1317
- const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
1318
- if (user && !user.roles.includes(role)) {
1319
- await db.update(users).set({ roles: [...user.roles, role] }).where(eq(users.id, userId));
1320
- }
1321
- },
1322
- async removeRole(userId, role) {
1323
- const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
1324
- if (user) {
1325
- await db.update(users).set({ roles: user.roles.filter((r: string) => r !== role) }).where(eq(users.id, userId));
1326
- }
1327
- },
1328
- };
1329
- ```
1330
-
1331
- ---
1332
-
1333
- ## Social Login (OAuth)
1334
-
1335
- Pass `auth.oauth.providers` to `createServer` to enable Google and/or Apple sign-in. Routes are mounted automatically for each configured provider.
1336
-
1337
- ```ts
1338
- await createServer({
1339
- routesDir: import.meta.dir + "/routes",
1340
- app: { name: "My App", version: "1.0.0" },
1341
- auth: {
1342
- oauth: {
1343
- postRedirect: "/lobby", // where to redirect after login (default: "/")
1344
- providers: {
1345
- google: {
1346
- clientId: process.env.GOOGLE_CLIENT_ID!,
1347
- clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
1348
- redirectUri: "https://myapp.com/auth/google/callback",
1349
- },
1350
- apple: {
1351
- clientId: process.env.APPLE_CLIENT_ID!, // Services ID, e.g. "com.myapp.auth"
1352
- teamId: process.env.APPLE_TEAM_ID!,
1353
- keyId: process.env.APPLE_KEY_ID!,
1354
- privateKey: process.env.APPLE_PRIVATE_KEY!, // PEM string
1355
- redirectUri: "https://myapp.com/auth/apple/callback",
1356
- },
1357
- },
1358
- },
1359
- },
1360
- });
1361
- ```
1362
-
1363
- ### Routes mounted automatically
1364
-
1365
- | Provider | Initiate login | Callback | Link to existing account | Unlink |
1366
- |---|---|---|---|---|
1367
- | Google | `GET /auth/google` | `GET /auth/google/callback` | `GET /auth/google/link` | `DELETE /auth/google/link` |
1368
- | Apple | `GET /auth/apple` | `POST /auth/apple/callback` | `GET /auth/apple/link` | — |
1369
-
1370
- > 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.
1371
-
1372
- ### Flow
1373
-
1374
- 1. Client navigates to `GET /auth/google` (or `/auth/apple`)
1375
- 2. Package redirects to the provider's OAuth page
1376
- 3. Provider redirects (or POSTs) back to the callback URL
1377
- 4. Package exchanges the code, fetches the user profile, and calls `authAdapter.findOrCreateByProvider`
1378
- 5. A session is created, the `auth-token` cookie is set, and the user is redirected to `auth.oauth.postRedirect`
1379
-
1380
- ### User storage
1381
-
1382
- 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.
1383
-
1384
- **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.").
1385
-
1386
- To support social login with a custom adapter, implement `findOrCreateByProvider`:
1387
-
1388
- ```ts
1389
- const myAdapter: AuthAdapter = {
1390
- findByEmail: ...,
1391
- create: ...,
1392
- async findOrCreateByProvider(provider, providerId, profile) {
1393
- // find or upsert user by provider + providerId
1394
- // return { id: string }
1395
- },
1396
- };
1397
- ```
1398
-
1399
- ### Linking a provider to an existing account
1400
-
1401
- 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.
1402
-
1403
- ```
1404
- GET /auth/google/link (requires active session via cookie)
1405
- GET /auth/apple/link (requires active session via cookie)
1406
- ```
1407
-
1408
- The link flow:
1409
- 1. User is already logged in (session cookie set)
1410
- 2. Client navigates to `/auth/google/link`
1411
- 3. User completes Google OAuth as normal
1412
- 4. On callback, instead of creating a new session, the Google identity is added to their existing account
1413
- 5. User is redirected to `auth.oauth.postRedirect?linked=google`
1414
-
1415
- To support linking with a custom adapter, implement `linkProvider`:
1416
-
1417
- ```ts
1418
- const myAdapter: AuthAdapter = {
1419
- // ...
1420
- async linkProvider(userId, provider, providerId) {
1421
- const key = `${provider}:${providerId}`;
1422
- await db.update(users)
1423
- .set({ providerIds: sql`array_append(provider_ids, ${key})` })
1424
- .where(eq(users.id, userId));
1425
- },
1426
- };
1427
- ```
1428
-
1429
- ### Unlinking a provider
1430
-
1431
- A logged-in user can remove a linked Google identity via:
1432
-
1433
- ```
1434
- DELETE /auth/google/link (requires active session via cookie)
1435
- ```
1436
-
1437
- Returns `204 No Content` on success. All `google:*` entries are removed from the user's `providerIds`.
1438
-
1439
- To support unlinking with a custom adapter, implement `unlinkProvider`:
1440
-
1441
- ```ts
1442
- const myAdapter: AuthAdapter = {
1443
- // ...
1444
- async unlinkProvider(userId, provider) {
1445
- const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
1446
- if (!user) throw new HttpError(404, "User not found");
1447
- const filtered = user.providerIds.filter((id: string) => !id.startsWith(`${provider}:`));
1448
- await db.update(users).set({ providerIds: filtered }).where(eq(users.id, userId));
1449
- },
1450
- };
1451
- ```
1452
-
1453
- ---
1454
-
1455
- ## Environment Variables
1456
-
1457
- ```env
1458
- NODE_ENV=development
1459
- PORT=...
1460
-
1461
- # MongoDB (single connection — used by connectMongo())
1462
- MONGO_USER_DEV=...
1463
- MONGO_PW_DEV=...
1464
- MONGO_HOST_DEV=...
1465
- MONGO_DB_DEV=...
1466
- MONGO_USER_PROD=...
1467
- MONGO_PW_PROD=...
1468
- MONGO_HOST_PROD=...
1469
- MONGO_DB_PROD=...
1470
-
1471
- # MongoDB auth connection (separate server — used by connectAuthMongo())
1472
- # Only needed when running auth on a different cluster from app data
1473
- MONGO_AUTH_USER_DEV=...
1474
- MONGO_AUTH_PW_DEV=...
1475
- MONGO_AUTH_HOST_DEV=...
1476
- MONGO_AUTH_DB_DEV=...
1477
- MONGO_AUTH_USER_PROD=...
1478
- MONGO_AUTH_PW_PROD=...
1479
- MONGO_AUTH_HOST_PROD=...
1480
- MONGO_AUTH_DB_PROD=...
1481
-
1482
- # Redis
1483
- REDIS_HOST_DEV=host:port
1484
- REDIS_USER_DEV=...
1485
- REDIS_PW_DEV=...
1486
- REDIS_HOST_PROD=host:port
1487
- REDIS_USER_PROD=...
1488
- REDIS_PW_PROD=...
1489
-
1490
- # JWT
1491
- JWT_SECRET_DEV=...
1492
- JWT_SECRET_PROD=...
1493
-
1494
- # Bearer API key (required on every non-bypassed request)
1495
- BEARER_TOKEN_DEV=...
1496
- BEARER_TOKEN_PROD=...
1497
-
1498
- # Logging (optional defaults to on in dev)
1499
- LOGGING_VERBOSE=true
1500
-
1501
- # OAuth (only needed if using oauthProviders)
1502
- GOOGLE_CLIENT_ID=...
1503
- GOOGLE_CLIENT_SECRET=...
1504
-
1505
- APPLE_CLIENT_ID=...
1506
- APPLE_TEAM_ID=...
1507
- APPLE_KEY_ID=...
1508
- APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
1509
- ```
1510
-
1511
- ---
1512
-
1513
- ## Package Development
1514
-
1515
- To test changes locally, install the package from the local path in a sibling project:
1516
-
1517
- ```bash
1518
- bun add @lastshotlabs/bunshot@file:../bunshot
1519
- ```
1520
-
1521
- ---
1522
-
1523
- ## Exports
1524
-
1525
- ```ts
1526
- import {
1527
- // Server factory
1528
- createServer, createApp,
1529
-
1530
- // DB
1531
- connectMongo, connectAuthMongo, connectAppMongo, disconnectMongo,
1532
- authConnection, appConnection, mongoose,
1533
- connectRedis, disconnectRedis, getRedis,
1534
-
1535
- // Jobs
1536
- createQueue, createWorker,
1537
- type Job,
1538
-
1539
- // WebSocket
1540
- websocket, createWsUpgradeHandler, publish,
1541
- subscribe, unsubscribe, getSubscriptions, handleRoomActions,
1542
- getRooms, getRoomSubscribers,
1543
-
1544
- // Auth utilities
1545
- signToken, verifyToken,
1546
- createSession, getSession, deleteSession, getUserSessions, getActiveSessionCount,
1547
- evictOldestSession, updateSessionLastActive, setSessionStore,
1548
- createVerificationToken, getVerificationToken, deleteVerificationToken, // email verification tokens
1549
- bustAuthLimit, trackAttempt, isLimited, // auth rate limiting — use in custom routes or admin unlocks
1550
- buildFingerprint, // HTTP fingerprint hash (IP-independent) — use in custom bot detection logic
1551
- AuthUser, mongoAuthAdapter,
1552
- sqliteAuthAdapter, setSqliteDb, startSqliteCleanup, // SQLite backend (persisted)
1553
- memoryAuthAdapter, clearMemoryStore, // in-memory backend (ephemeral)
1554
- setUserRoles, addUserRole, removeUserRole, // role management
1555
- type AuthAdapter, type OAuthProfile, type OAuthProviderConfig,
1556
- type AuthRateLimitConfig, type BotProtectionConfig, type BotProtectionOptions,
1557
- type LimitOpts, type RateLimitOptions,
1558
-
1559
- // Middleware
1560
- bearerAuth, identify, userAuth, rateLimit,
1561
- botProtection, // CIDR blocklist + per-route bot protection
1562
- requireRole, // role-based access control
1563
- requireVerifiedEmail, // blocks unverified email addresses
1564
- cacheResponse, bustCache, bustCachePattern, setCacheStore, // response caching
1565
-
1566
- // Utilities
1567
- HttpError, log, validate, createRouter,
1568
- getAppRoles, // returns the valid roles list configured at startup
1569
-
1570
- // Constants
1571
- COOKIE_TOKEN, HEADER_USER_TOKEN,
1572
-
1573
- // Types
1574
- type AppEnv, type AppVariables,
1575
- type CreateServerConfig, type CreateAppConfig,
1576
- type DbConfig, type AppMeta, type AuthConfig, type OAuthConfig, type SecurityConfig,
1577
- type PrimaryField, type EmailVerificationConfig,
1578
- type SocketData, type WsConfig,
1579
- } from "@lastshotlabs/bunshot";
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 unchangedfully 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
+ ```