@lastshotlabs/bunshot 0.0.1

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