@lastshotlabs/bunshot 0.0.13 → 0.0.18

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