@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.
- package/CLAUDE.md +102 -0
- package/README.md +1458 -0
- package/bun.lock +170 -0
- package/package.json +47 -0
- package/src/adapters/memoryAuth.ts +240 -0
- package/src/adapters/mongoAuth.ts +91 -0
- package/src/adapters/sqliteAuth.ts +320 -0
- package/src/app.ts +368 -0
- package/src/cli.ts +265 -0
- package/src/index.ts +52 -0
- package/src/lib/HttpError.ts +5 -0
- package/src/lib/appConfig.ts +29 -0
- package/src/lib/authAdapter.ts +46 -0
- package/src/lib/authRateLimit.ts +104 -0
- package/src/lib/constants.ts +2 -0
- package/src/lib/context.ts +17 -0
- package/src/lib/emailVerification.ts +105 -0
- package/src/lib/fingerprint.ts +43 -0
- package/src/lib/jwt.ts +17 -0
- package/src/lib/logger.ts +9 -0
- package/src/lib/mongo.ts +70 -0
- package/src/lib/oauth.ts +114 -0
- package/src/lib/queue.ts +18 -0
- package/src/lib/redis.ts +45 -0
- package/src/lib/roles.ts +23 -0
- package/src/lib/session.ts +91 -0
- package/src/lib/validate.ts +14 -0
- package/src/lib/ws.ts +82 -0
- package/src/middleware/bearerAuth.ts +15 -0
- package/src/middleware/botProtection.ts +73 -0
- package/src/middleware/cacheResponse.ts +189 -0
- package/src/middleware/cors.ts +19 -0
- package/src/middleware/errorHandler.ts +14 -0
- package/src/middleware/identify.ts +36 -0
- package/src/middleware/index.ts +8 -0
- package/src/middleware/logger.ts +9 -0
- package/src/middleware/rateLimit.ts +37 -0
- package/src/middleware/requireRole.ts +42 -0
- package/src/middleware/requireVerifiedEmail.ts +31 -0
- package/src/middleware/userAuth.ts +9 -0
- package/src/models/AuthUser.ts +17 -0
- package/src/routes/auth.ts +245 -0
- package/src/routes/health.ts +27 -0
- package/src/routes/home.ts +21 -0
- package/src/routes/oauth.ts +174 -0
- package/src/schemas/auth.ts +14 -0
- package/src/server.ts +91 -0
- package/src/services/auth.ts +59 -0
- package/src/ws/index.ts +42 -0
- 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
|
+
```
|