@lastshotlabs/bunshot 0.0.9 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +241 -7
- package/dist/adapters/memoryAuth.d.ts +5 -0
- package/dist/adapters/memoryAuth.js +23 -0
- package/dist/adapters/sqliteAuth.d.ts +5 -0
- package/dist/adapters/sqliteAuth.js +18 -0
- package/dist/app.d.ts +48 -2
- package/dist/app.js +72 -5
- package/dist/entrypoints/mongo.d.ts +3 -0
- package/dist/entrypoints/mongo.js +3 -0
- package/dist/entrypoints/queue.d.ts +2 -0
- package/dist/entrypoints/queue.js +1 -0
- package/dist/entrypoints/redis.d.ts +1 -0
- package/dist/entrypoints/redis.js +1 -0
- package/dist/index.d.ts +5 -7
- package/dist/index.js +5 -5
- package/dist/lib/appConfig.d.ts +9 -0
- package/dist/lib/appConfig.js +5 -0
- package/dist/lib/createRoute.d.ts +61 -0
- package/dist/lib/createRoute.js +147 -0
- package/dist/lib/emailVerification.js +11 -10
- package/dist/lib/mongo.d.ts +9 -4
- package/dist/lib/mongo.js +61 -10
- package/dist/lib/oauth.js +11 -10
- package/dist/lib/queue.d.ts +3 -4
- package/dist/lib/queue.js +18 -3
- package/dist/lib/redis.d.ts +3 -8
- package/dist/lib/redis.js +19 -8
- package/dist/lib/resetPassword.d.ts +12 -0
- package/dist/lib/resetPassword.js +95 -0
- package/dist/lib/session.js +12 -12
- package/dist/middleware/cacheResponse.js +10 -9
- package/dist/models/AuthUser.d.ts +14 -106
- package/dist/models/AuthUser.js +31 -14
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +176 -59
- package/dist/services/auth.d.ts +8 -1
- package/dist/services/auth.js +5 -3
- package/package.json +38 -8
package/README.md
CHANGED
|
@@ -60,6 +60,48 @@ bun add @lastshotlabs/bunshot
|
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
+
## Peer Dependencies
|
|
64
|
+
|
|
65
|
+
Bunshot declares the following as peer dependencies so you control their versions and avoid duplicate installs in your app.
|
|
66
|
+
|
|
67
|
+
### Required
|
|
68
|
+
|
|
69
|
+
These must be installed in every consuming app:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bun add hono zod
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Package | Required version |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `hono` | `>=4.12 <5` |
|
|
78
|
+
| `zod` | `>=4.0 <5` |
|
|
79
|
+
|
|
80
|
+
### Optional
|
|
81
|
+
|
|
82
|
+
Install only what your app actually uses:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# MongoDB auth / sessions / cache
|
|
86
|
+
bun add mongoose
|
|
87
|
+
|
|
88
|
+
# Redis sessions, cache, rate limiting, or BullMQ
|
|
89
|
+
bun add ioredis
|
|
90
|
+
|
|
91
|
+
# Background job queues
|
|
92
|
+
bun add bullmq
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Package | Required version | When you need it |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `mongoose` | `>=9.0 <10` | `db.auth: "mongo"`, `db.sessions: "mongo"`, or `db.cache: "mongo"` |
|
|
98
|
+
| `ioredis` | `>=5.0 <6` | `db.redis: true` (the default), or any store set to `"redis"` |
|
|
99
|
+
| `bullmq` | `>=5.0 <6` | Workers / queues |
|
|
100
|
+
|
|
101
|
+
If you're running fully on SQLite or memory (no Redis, no MongoDB), none of the optional peers are needed.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
63
105
|
## Quick Start
|
|
64
106
|
|
|
65
107
|
```ts
|
|
@@ -85,6 +127,8 @@ That's it. Your app gets:
|
|
|
85
127
|
| `DELETE /auth/sessions/:sessionId` | Revoke a specific session by ID (requires login) |
|
|
86
128
|
| `POST /auth/verify-email` | Verify email with token (when `emailVerification` is configured) |
|
|
87
129
|
| `POST /auth/resend-verification` | Resend verification email (requires login, when `emailVerification` is configured) |
|
|
130
|
+
| `POST /auth/forgot-password` | Request a password reset email (when `passwordReset` is configured) |
|
|
131
|
+
| `POST /auth/reset-password` | Reset password using a token from the reset email (when `passwordReset` is configured) |
|
|
88
132
|
| `GET /health` | Health check |
|
|
89
133
|
| `GET /docs` | Scalar API docs UI |
|
|
90
134
|
| `GET /openapi.json` | OpenAPI spec |
|
|
@@ -98,9 +142,8 @@ Drop a file in your `routes/` directory. It must export a `router`:
|
|
|
98
142
|
|
|
99
143
|
```ts
|
|
100
144
|
// src/routes/products.ts
|
|
101
|
-
import { createRoute } from "@hono/zod-openapi";
|
|
102
145
|
import { z } from "zod";
|
|
103
|
-
import { createRouter, userAuth } from "@lastshotlabs/bunshot";
|
|
146
|
+
import { createRoute, createRouter, userAuth } from "@lastshotlabs/bunshot";
|
|
104
147
|
|
|
105
148
|
export const router = createRouter();
|
|
106
149
|
|
|
@@ -134,6 +177,168 @@ routes/
|
|
|
134
177
|
detail.ts
|
|
135
178
|
```
|
|
136
179
|
|
|
180
|
+
### OpenAPI Schema Registration
|
|
181
|
+
|
|
182
|
+
Import `createRoute` from `@lastshotlabs/bunshot` (not from `@hono/zod-openapi`). The wrapper automatically registers every unnamed request body and response schema as a named entry in `components/schemas`. Schemas you already named via `registerSchema` are never overwritten.
|
|
183
|
+
|
|
184
|
+
Every Zod schema that appears in your OpenAPI spec ends up as a named entry in `components/schemas` — either auto-named by the framework or explicitly named by you. There are four registration methods, each suited to a different scenario.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
### Method 1 — Route-level auto-registration (via `createRoute`)
|
|
189
|
+
|
|
190
|
+
The most common case. When you define a route with `createRoute`, every unnamed request body and response schema is automatically registered under a name derived from the HTTP method and path.
|
|
191
|
+
|
|
192
|
+
**Naming convention**
|
|
193
|
+
|
|
194
|
+
| Route | Part | Generated name |
|
|
195
|
+
|-------|------|----------------|
|
|
196
|
+
| `POST /products` | request body | `CreateProductsRequest` |
|
|
197
|
+
| `POST /products` | 201 response | `CreateProductsResponse` |
|
|
198
|
+
| `GET /products/{id}` | 200 response | `GetProductsByIdResponse` |
|
|
199
|
+
| `DELETE /products/{id}` | 404 response | `DeleteProductsByIdNotFoundError` |
|
|
200
|
+
| `PATCH /products/{id}` | request body | `UpdateProductsByIdRequest` |
|
|
201
|
+
|
|
202
|
+
HTTP methods → verbs: `GET → Get`, `POST → Create`, `PUT → Replace`, `PATCH → Update`, `DELETE → Delete`.
|
|
203
|
+
|
|
204
|
+
Status codes → suffixes: `200/201/204 → Response`, `400 → BadRequestError`, `401 → UnauthorizedError`, `403 → ForbiddenError`, `404 → NotFoundError`, `409 → ConflictError`, `422 → ValidationError`, `429 → RateLimitError`, `500 → InternalError`, `501 → NotImplementedError`, `503 → UnavailableError`. Unknown codes fall back to the number.
|
|
205
|
+
|
|
206
|
+
**Limitation:** if the same Zod object is used in two different routes, each route names it after itself — you get two identical inline shapes instead of one shared `$ref`. Use Method 2 or 3 to fix this.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
### Method 2 — Directory / glob auto-discovery (via `modelSchemas`)
|
|
211
|
+
|
|
212
|
+
Use this when you have schemas shared across multiple routes. Point `modelSchemas` at one or more directories and Bunshot imports every `.ts` file **before** routes are loaded. Any exported Zod schema is registered automatically — same object referenced in multiple routes → same `$ref` in the spec.
|
|
213
|
+
|
|
214
|
+
**Naming:** export name with the trailing `Schema` suffix stripped (`LedgerItemSchema` → `"LedgerItem"`). Already-registered schemas are never overwritten.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// src/schemas/ledgerItem.ts
|
|
218
|
+
import { z } from "zod";
|
|
219
|
+
export const LedgerItemSchema = z.object({ id: z.string(), name: z.string(), amount: z.number() });
|
|
220
|
+
// → auto-registered as "LedgerItem"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// src/config/index.ts
|
|
225
|
+
await createServer({
|
|
226
|
+
routesDir: import.meta.dir + "/routes",
|
|
227
|
+
modelSchemas: import.meta.dir + "/schemas", // string shorthand — registration: "auto"
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
// src/routes/ledger.ts AND src/routes/ledgerDetail.ts
|
|
233
|
+
import { LedgerItemSchema } from "@schemas/ledgerItem"; // same Zod object instance
|
|
234
|
+
createRoute({ responses: { 200: { content: { "application/json": { schema: LedgerItemSchema } } } } });
|
|
235
|
+
// → $ref: "#/components/schemas/LedgerItem" in both routes
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Multiple directories and glob patterns**
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
modelSchemas: [
|
|
242
|
+
import.meta.dir + "/schemas", // dedicated schemas dir
|
|
243
|
+
import.meta.dir + "/models", // co-located with DB models
|
|
244
|
+
import.meta.dir + "/services/**/*.schema.ts", // selective glob
|
|
245
|
+
]
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Full config object** — use when you need to set `registration` or mix paths and globs:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
modelSchemas: {
|
|
252
|
+
paths: [import.meta.dir + "/schemas", import.meta.dir + "/models"],
|
|
253
|
+
registration: "auto", // default — auto-registers exports with suffix stripping
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**`registration: "explicit"`** — files are imported but nothing is auto-registered. Registration is left entirely to `registerSchema` / `registerSchemas` calls inside each file. Use this when you want zero magic and full name control:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
modelSchemas: { paths: import.meta.dir + "/schemas", registration: "explicit" }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
### Method 3 — Batch explicit registration (via `registerSchemas`)
|
|
266
|
+
|
|
267
|
+
`registerSchemas` lets you name a group of schemas all at once. Object keys become the `components/schemas` names; the same object is returned so you can destructure and export normally. No suffix stripping — names are taken as-is.
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
// src/schemas/index.ts
|
|
271
|
+
import { registerSchemas } from "@lastshotlabs/bunshot";
|
|
272
|
+
import { z } from "zod";
|
|
273
|
+
|
|
274
|
+
export const { LedgerItem, Product, ErrorResponse } = registerSchemas({
|
|
275
|
+
LedgerItem: z.object({ id: z.string(), name: z.string(), amount: z.number() }),
|
|
276
|
+
Product: z.object({ id: z.string(), price: z.number() }),
|
|
277
|
+
ErrorResponse: z.object({ error: z.string() }),
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Pair with `registration: "explicit"` in `modelSchemas` so the file is imported before routes, or call it inline at the top of any route file — route files are auto-discovered so the top-level call runs before the spec is served.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
### Method 4 — Single explicit registration (via `registerSchema`)
|
|
286
|
+
|
|
287
|
+
`registerSchema("Name", schema)` registers one schema and returns it unchanged. Useful for a single shared type (e.g. a common error envelope) or to override the name auto-discovery would generate.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
// src/schemas/errors.ts
|
|
291
|
+
import { registerSchema } from "@lastshotlabs/bunshot";
|
|
292
|
+
import { z } from "zod";
|
|
293
|
+
|
|
294
|
+
export const ErrorResponse = registerSchema("ErrorResponse",
|
|
295
|
+
z.object({ error: z.string() })
|
|
296
|
+
);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Registration is idempotent — calling `registerSchema` on an already-registered schema is a no-op. This means you can safely call it in files that are also covered by `modelSchemas` auto-discovery: whichever runs first wins, and the other is silently skipped.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
### Priority and interaction
|
|
304
|
+
|
|
305
|
+
All four methods write to the same process-global registry. The rules are simple:
|
|
306
|
+
|
|
307
|
+
1. **First write wins** — once a schema has a name, it cannot be renamed.
|
|
308
|
+
2. **`modelSchemas` files are imported before routes**, so explicit calls inside them always take precedence over what `createRoute` would generate for the same object.
|
|
309
|
+
3. **`registerSchema` / `registerSchemas` take precedence over auto-discovery** when they appear at module top level (they run at import time, before `maybeAutoRegister` inspects the export list).
|
|
310
|
+
4. **`createRoute` never overwrites** a schema already in the registry — it only fills gaps.
|
|
311
|
+
|
|
312
|
+
**Decision guide:**
|
|
313
|
+
|
|
314
|
+
| Situation | Use |
|
|
315
|
+
|-----------|-----|
|
|
316
|
+
| Route-specific, one-off schema | `createRoute` auto-registration (Method 1) |
|
|
317
|
+
| Shared across routes, happy with suffix-stripped export name | `modelSchemas` auto-discovery (Method 2) |
|
|
318
|
+
| Shared across routes, want explicit names or batch control | `registerSchemas` (Method 3) |
|
|
319
|
+
| Single shared schema or custom name override | `registerSchema` (Method 4) |
|
|
320
|
+
|
|
321
|
+
**Protected routes**
|
|
322
|
+
|
|
323
|
+
Use `withSecurity` to declare security schemes on a route without breaking `c.req.valid()` type inference. (Inlining `security` directly in `createRoute({...})` causes TypeScript to collapse the handler's input types to `never`.)
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
import { createRoute, withSecurity } from "@lastshotlabs/bunshot";
|
|
327
|
+
|
|
328
|
+
router.openapi(
|
|
329
|
+
withSecurity(
|
|
330
|
+
createRoute({ method: "get", path: "/me", ... }),
|
|
331
|
+
{ cookieAuth: [] },
|
|
332
|
+
{ userToken: [] }
|
|
333
|
+
),
|
|
334
|
+
async (c) => {
|
|
335
|
+
const userId = c.get("authUserId"); // fully typed
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Pass each security scheme as a separate object argument. The security scheme names (`cookieAuth`, `userToken`, `bearerAuth`) are registered globally by `createApp`.
|
|
341
|
+
|
|
137
342
|
**Load order:** By default, routes load in filesystem order. If a route needs to be registered before another (e.g. for Hono's first-match-wins routing), export a `priority` number — lower values load first. Routes without a `priority` load last.
|
|
138
343
|
|
|
139
344
|
```ts
|
|
@@ -197,18 +402,31 @@ await createServer({
|
|
|
197
402
|
|
|
198
403
|
Import `appConnection` and register models on it. This ensures your models use the correct connection whether you're on a single DB or a separate tenant DB.
|
|
199
404
|
|
|
405
|
+
`appConnection` is a lazy proxy — calling `.model()` at the top level works fine even before `connectMongo()` has been called. Mongoose buffers any queries until the connection is established.
|
|
406
|
+
|
|
200
407
|
```ts
|
|
201
408
|
// src/models/Product.ts
|
|
202
|
-
import { appConnection
|
|
409
|
+
import { appConnection } from "@lastshotlabs/bunshot";
|
|
410
|
+
import { Schema } from "mongoose";
|
|
411
|
+
import type { HydratedDocument } from "mongoose";
|
|
203
412
|
|
|
204
|
-
|
|
413
|
+
interface IProduct {
|
|
414
|
+
name: string;
|
|
415
|
+
price: number;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export type ProductDocument = HydratedDocument<IProduct>;
|
|
419
|
+
|
|
420
|
+
const ProductSchema = new Schema<IProduct>({
|
|
205
421
|
name: { type: String, required: true },
|
|
206
422
|
price: { type: Number, required: true },
|
|
207
423
|
}, { timestamps: true });
|
|
208
424
|
|
|
209
|
-
export const Product = appConnection.model("Product", ProductSchema);
|
|
425
|
+
export const Product = appConnection.model<IProduct>("Product", ProductSchema);
|
|
210
426
|
```
|
|
211
427
|
|
|
428
|
+
> **Note:** Import types (`HydratedDocument`, `Schema`, etc.) directly from `"mongoose"` — the `appConnection` and `mongoose` exports from bunshot are runtime proxies and cannot be used as TypeScript namespaces.
|
|
429
|
+
|
|
212
430
|
---
|
|
213
431
|
|
|
214
432
|
## Jobs (BullMQ)
|
|
@@ -704,6 +922,11 @@ await createServer({
|
|
|
704
922
|
// Required
|
|
705
923
|
routesDir: import.meta.dir + "/routes",
|
|
706
924
|
|
|
925
|
+
// Shared schemas (imported before routes; see "Shared schemas across routes" above)
|
|
926
|
+
modelSchemas: import.meta.dir + "/schemas", // string shorthand — registration: "auto"
|
|
927
|
+
// modelSchemas: [dir + "/schemas", dir + "/models"], // multiple dirs
|
|
928
|
+
// modelSchemas: { paths: dir + "/schemas", registration: "explicit" }, // full object
|
|
929
|
+
|
|
707
930
|
// App metadata (shown in root endpoint + OpenAPI docs)
|
|
708
931
|
app: {
|
|
709
932
|
name: "My App", // default: "Bun Core API"
|
|
@@ -724,11 +947,19 @@ await createServer({
|
|
|
724
947
|
await resend.emails.send({ to: email, subject: "Verify your email", text: `Token: ${token}` });
|
|
725
948
|
},
|
|
726
949
|
},
|
|
950
|
+
passwordReset: { // optional — only active when primaryField is "email"
|
|
951
|
+
tokenExpiry: 60 * 60, // default: 3600 (1 hour) — token TTL in seconds
|
|
952
|
+
onSend: async (email, token) => { // called by POST /auth/forgot-password — use any email provider
|
|
953
|
+
await resend.emails.send({ to: email, subject: "Reset your password", text: `Token: ${token}` });
|
|
954
|
+
},
|
|
955
|
+
},
|
|
727
956
|
rateLimit: { // optional — built-in auth endpoint rate limiting
|
|
728
957
|
login: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 failures / 15 min
|
|
729
958
|
register: { windowMs: 60 * 60 * 1000, max: 5 }, // default: 5 attempts / hour (per IP)
|
|
730
959
|
verifyEmail: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
731
960
|
resendVerification: { windowMs: 60 * 60 * 1000, max: 3 }, // default: 3 attempts / hour (per user)
|
|
961
|
+
forgotPassword: { windowMs: 15 * 60 * 1000, max: 5 }, // default: 5 attempts / 15 min (per IP)
|
|
962
|
+
resetPassword: { windowMs: 15 * 60 * 1000, max: 10 }, // default: 10 attempts / 15 min (per IP)
|
|
732
963
|
store: "redis", // default: "redis" when Redis is enabled, else "memory"
|
|
733
964
|
},
|
|
734
965
|
sessionPolicy: { // optional — session concurrency and metadata
|
|
@@ -994,6 +1225,8 @@ All built-in auth endpoints are rate-limited out of the box with sensible defaul
|
|
|
994
1225
|
| `POST /auth/register` | IP address | Every attempt | 5 / hour |
|
|
995
1226
|
| `POST /auth/verify-email` | IP address | Every attempt | 10 / 15 min |
|
|
996
1227
|
| `POST /auth/resend-verification` | User ID (authenticated) | Every attempt | 3 / hour |
|
|
1228
|
+
| `POST /auth/forgot-password` | IP address | Every attempt | 5 / 15 min |
|
|
1229
|
+
| `POST /auth/reset-password` | IP address | Every attempt | 10 / 15 min |
|
|
997
1230
|
|
|
998
1231
|
Login is keyed by the **identifier being targeted** — an attacker rotating IPs to brute-force `alice@example.com` is blocked regardless of source IP. A successful login resets the counter so legitimate users aren't locked out.
|
|
999
1232
|
|
|
@@ -1497,7 +1730,8 @@ import {
|
|
|
1497
1730
|
cacheResponse, bustCache, bustCachePattern, setCacheStore, // response caching
|
|
1498
1731
|
|
|
1499
1732
|
// Utilities
|
|
1500
|
-
HttpError, log, validate, createRouter,
|
|
1733
|
+
HttpError, log, validate, createRouter, createRoute,
|
|
1734
|
+
registerSchema, registerSchemas, // named OpenAPI schema registration
|
|
1501
1735
|
getAppRoles, // returns the valid roles list configured at startup
|
|
1502
1736
|
|
|
1503
1737
|
// Constants
|
|
@@ -1505,7 +1739,7 @@ import {
|
|
|
1505
1739
|
|
|
1506
1740
|
// Types
|
|
1507
1741
|
type AppEnv, type AppVariables,
|
|
1508
|
-
type CreateServerConfig, type CreateAppConfig,
|
|
1742
|
+
type CreateServerConfig, type CreateAppConfig, type ModelSchemasConfig,
|
|
1509
1743
|
type DbConfig, type AppMeta, type AuthConfig, type OAuthConfig, type SecurityConfig,
|
|
1510
1744
|
type PrimaryField, type EmailVerificationConfig,
|
|
1511
1745
|
type SocketData, type WsConfig,
|
|
@@ -25,3 +25,8 @@ export declare const memoryGetVerificationToken: (token: string) => {
|
|
|
25
25
|
email: string;
|
|
26
26
|
} | null;
|
|
27
27
|
export declare const memoryDeleteVerificationToken: (token: string) => void;
|
|
28
|
+
export declare const memoryCreateResetToken: (token: string, userId: string, email: string, ttlSeconds: number) => void;
|
|
29
|
+
export declare const memoryConsumeResetToken: (hash: string) => {
|
|
30
|
+
userId: string;
|
|
31
|
+
email: string;
|
|
32
|
+
} | null;
|
|
@@ -7,6 +7,7 @@ const _userSessionIds = new Map(); // userId → Set<sessionId>
|
|
|
7
7
|
const _oauthStates = new Map();
|
|
8
8
|
const _cache = new Map();
|
|
9
9
|
const _verificationTokens = new Map();
|
|
10
|
+
const _resetTokens = new Map();
|
|
10
11
|
/** Reset all in-memory state. Useful for test isolation. */
|
|
11
12
|
export const clearMemoryStore = () => {
|
|
12
13
|
_users.clear();
|
|
@@ -16,6 +17,7 @@ export const clearMemoryStore = () => {
|
|
|
16
17
|
_oauthStates.clear();
|
|
17
18
|
_cache.clear();
|
|
18
19
|
_verificationTokens.clear();
|
|
20
|
+
_resetTokens.clear();
|
|
19
21
|
};
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Auth adapter
|
|
@@ -290,3 +292,24 @@ export const memoryGetVerificationToken = (token) => {
|
|
|
290
292
|
export const memoryDeleteVerificationToken = (token) => {
|
|
291
293
|
_verificationTokens.delete(token);
|
|
292
294
|
};
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Password reset token helpers (used by src/lib/resetPassword.ts)
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
export const memoryCreateResetToken = (token, userId, email, ttlSeconds) => {
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
// Opportunistically purge expired entries to prevent unbounded memory growth
|
|
301
|
+
for (const [k, v] of _resetTokens) {
|
|
302
|
+
if (v.expiresAt <= now)
|
|
303
|
+
_resetTokens.delete(k);
|
|
304
|
+
}
|
|
305
|
+
_resetTokens.set(token, { userId, email, expiresAt: now + ttlSeconds * 1000 });
|
|
306
|
+
};
|
|
307
|
+
export const memoryConsumeResetToken = (hash) => {
|
|
308
|
+
const entry = _resetTokens.get(hash);
|
|
309
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
310
|
+
_resetTokens.delete(hash);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
_resetTokens.delete(hash);
|
|
314
|
+
return { userId: entry.userId, email: entry.email };
|
|
315
|
+
};
|
|
@@ -25,4 +25,9 @@ export declare const sqliteGetVerificationToken: (token: string) => {
|
|
|
25
25
|
email: string;
|
|
26
26
|
} | null;
|
|
27
27
|
export declare const sqliteDeleteVerificationToken: (token: string) => void;
|
|
28
|
+
export declare const sqliteCreateResetToken: (token: string, userId: string, email: string, ttlSeconds: number) => void;
|
|
29
|
+
export declare const sqliteConsumeResetToken: (hash: string) => {
|
|
30
|
+
userId: string;
|
|
31
|
+
email: string;
|
|
32
|
+
} | null;
|
|
28
33
|
export declare const startSqliteCleanup: (intervalMs?: number) => ReturnType<typeof setInterval>;
|
|
@@ -65,6 +65,12 @@ function initSchema(db) {
|
|
|
65
65
|
email TEXT NOT NULL,
|
|
66
66
|
expiresAt INTEGER NOT NULL
|
|
67
67
|
)`);
|
|
68
|
+
db.run(`CREATE TABLE IF NOT EXISTS password_resets (
|
|
69
|
+
token TEXT PRIMARY KEY,
|
|
70
|
+
userId TEXT NOT NULL,
|
|
71
|
+
email TEXT NOT NULL,
|
|
72
|
+
expiresAt INTEGER NOT NULL
|
|
73
|
+
)`);
|
|
68
74
|
}
|
|
69
75
|
// ---------------------------------------------------------------------------
|
|
70
76
|
// Auth adapter
|
|
@@ -282,6 +288,17 @@ export const sqliteDeleteVerificationToken = (token) => {
|
|
|
282
288
|
getDb().run("DELETE FROM email_verifications WHERE token = ?", [token]);
|
|
283
289
|
};
|
|
284
290
|
// ---------------------------------------------------------------------------
|
|
291
|
+
// Password reset token helpers (used by src/lib/resetPassword.ts)
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
export const sqliteCreateResetToken = (token, userId, email, ttlSeconds) => {
|
|
294
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
295
|
+
getDb().run("INSERT INTO password_resets (token, userId, email, expiresAt) VALUES (?, ?, ?, ?)", [token, userId, email, expiresAt]);
|
|
296
|
+
};
|
|
297
|
+
export const sqliteConsumeResetToken = (hash) => {
|
|
298
|
+
const row = getDb().query("DELETE FROM password_resets WHERE token = ? AND expiresAt > ? RETURNING userId, email").get(hash, Date.now());
|
|
299
|
+
return row ?? null;
|
|
300
|
+
};
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
285
302
|
// Optional periodic cleanup of expired rows
|
|
286
303
|
// ---------------------------------------------------------------------------
|
|
287
304
|
export const startSqliteCleanup = (intervalMs = 3_600_000) => {
|
|
@@ -298,5 +315,6 @@ export const startSqliteCleanup = (intervalMs = 3_600_000) => {
|
|
|
298
315
|
db.run("DELETE FROM oauth_states WHERE expiresAt <= ?", [now]);
|
|
299
316
|
db.run("DELETE FROM cache_entries WHERE expiresAt IS NOT NULL AND expiresAt <= ?", [now]);
|
|
300
317
|
db.run("DELETE FROM email_verifications WHERE expiresAt <= ?", [now]);
|
|
318
|
+
db.run("DELETE FROM password_resets WHERE expiresAt <= ?", [now]);
|
|
301
319
|
}, intervalMs);
|
|
302
320
|
};
|
package/dist/app.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { MiddlewareHandler } from "hono";
|
|
3
3
|
import type { AppEnv } from "./lib/context";
|
|
4
|
-
import type { PrimaryField, EmailVerificationConfig } from "./lib/appConfig";
|
|
4
|
+
import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig } from "./lib/appConfig";
|
|
5
5
|
import type { AuthAdapter } from "./lib/authAdapter";
|
|
6
6
|
import type { OAuthProviderConfig } from "./lib/oauth";
|
|
7
7
|
type StoreType = "redis" | "mongo" | "sqlite" | "memory";
|
|
@@ -82,6 +82,16 @@ export interface AuthRateLimitConfig {
|
|
|
82
82
|
windowMs?: number;
|
|
83
83
|
max?: number;
|
|
84
84
|
};
|
|
85
|
+
/** Max forgot-password requests per IP per window. Default: 5 per 15 min. */
|
|
86
|
+
forgotPassword?: {
|
|
87
|
+
windowMs?: number;
|
|
88
|
+
max?: number;
|
|
89
|
+
};
|
|
90
|
+
/** Max reset-password attempts per IP per window. Default: 10 per 15 min. */
|
|
91
|
+
resetPassword?: {
|
|
92
|
+
windowMs?: number;
|
|
93
|
+
max?: number;
|
|
94
|
+
};
|
|
85
95
|
/**
|
|
86
96
|
* Store backend for auth rate limit counters.
|
|
87
97
|
* Defaults to "redis" when Redis is enabled, otherwise "memory".
|
|
@@ -116,6 +126,12 @@ export interface AuthConfig {
|
|
|
116
126
|
* Provide an onSend callback to send the verification email via any provider (Resend, SendGrid, etc.).
|
|
117
127
|
*/
|
|
118
128
|
emailVerification?: EmailVerificationConfig;
|
|
129
|
+
/**
|
|
130
|
+
* Password reset configuration. Only active when primaryField is "email".
|
|
131
|
+
* Provide an onSend callback to send the reset email via any provider (Resend, SendGrid, etc.).
|
|
132
|
+
* Mounts POST /auth/forgot-password and POST /auth/reset-password.
|
|
133
|
+
*/
|
|
134
|
+
passwordReset?: PasswordResetConfig;
|
|
119
135
|
/** Rate limit configuration for built-in auth endpoints. */
|
|
120
136
|
rateLimit?: AuthRateLimitConfig;
|
|
121
137
|
/** Session concurrency and metadata persistence policy. */
|
|
@@ -140,7 +156,7 @@ export interface AuthSessionPolicyConfig {
|
|
|
140
156
|
*/
|
|
141
157
|
trackLastActive?: boolean;
|
|
142
158
|
}
|
|
143
|
-
export type { PrimaryField, EmailVerificationConfig };
|
|
159
|
+
export type { PrimaryField, EmailVerificationConfig, PasswordResetConfig };
|
|
144
160
|
export interface BotProtectionConfig {
|
|
145
161
|
/**
|
|
146
162
|
* List of IPv4 CIDRs (e.g. "198.51.100.0/24"), IPv4 addresses, or IPv6 addresses to block outright.
|
|
@@ -178,9 +194,39 @@ export interface SecurityConfig {
|
|
|
178
194
|
*/
|
|
179
195
|
botProtection?: BotProtectionConfig;
|
|
180
196
|
}
|
|
197
|
+
export interface ModelSchemasConfig {
|
|
198
|
+
/**
|
|
199
|
+
* One or more absolute directory paths or glob patterns containing shared Zod schemas.
|
|
200
|
+
* All matching .ts files are imported before routes so schemas are registered first.
|
|
201
|
+
* Optional when registration is "explicit" — in that case your registerSchema /
|
|
202
|
+
* registerSchemas calls run at the time each schema file is imported by a route.
|
|
203
|
+
* Examples:
|
|
204
|
+
* import.meta.dir + "/schemas"
|
|
205
|
+
* [import.meta.dir + "/schemas", import.meta.dir + "/models"]
|
|
206
|
+
* import.meta.dir + "/models/**\/*.schema.ts"
|
|
207
|
+
*/
|
|
208
|
+
paths?: string | string[];
|
|
209
|
+
/**
|
|
210
|
+
* How schemas found in the files are registered in `components/schemas`.
|
|
211
|
+
* - "auto" (default): exported Zod schemas are registered automatically. The export
|
|
212
|
+
* name is used as the schema name, with a trailing "Schema" suffix stripped
|
|
213
|
+
* (e.g. `LedgerItemSchema` → `"LedgerItem"`). Schemas already registered via
|
|
214
|
+
* `registerSchema` or `registerSchemas` inside the file are never overwritten.
|
|
215
|
+
* - "explicit": files are imported but registration is entirely up to the user —
|
|
216
|
+
* call `registerSchema` or `registerSchemas` inside each file.
|
|
217
|
+
*/
|
|
218
|
+
registration?: "auto" | "explicit";
|
|
219
|
+
}
|
|
181
220
|
export interface CreateAppConfig {
|
|
182
221
|
/** Absolute path to the service's routes directory (use import.meta.dir + "/routes") */
|
|
183
222
|
routesDir: string;
|
|
223
|
+
/**
|
|
224
|
+
* Shared Zod schema sources. Files are imported before route discovery so schemas
|
|
225
|
+
* are registered before any route references them.
|
|
226
|
+
* Accepts a directory path, an array of paths/globs, or a full ModelSchemasConfig object.
|
|
227
|
+
* Shorthand string/array defaults to registration: "auto".
|
|
228
|
+
*/
|
|
229
|
+
modelSchemas?: string | string[] | ModelSchemasConfig;
|
|
184
230
|
/** App name and version for the root endpoint and OpenAPI docs */
|
|
185
231
|
app?: AppMeta;
|
|
186
232
|
/** Auth, roles, and OAuth configuration */
|
package/dist/app.js
CHANGED
|
@@ -8,8 +8,9 @@ import { rateLimit } from "./middleware/rateLimit";
|
|
|
8
8
|
import { bearerAuth } from "./middleware/bearerAuth";
|
|
9
9
|
import { identify } from "./middleware/identify";
|
|
10
10
|
import { HEADER_USER_TOKEN } from "./lib/constants";
|
|
11
|
-
import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive } from "./lib/appConfig";
|
|
11
|
+
import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setPasswordResetConfig, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive } from "./lib/appConfig";
|
|
12
12
|
import { setEmailVerificationStore } from "./lib/emailVerification";
|
|
13
|
+
import { setPasswordResetStore } from "./lib/resetPassword";
|
|
13
14
|
import { setAuthRateLimitStore } from "./lib/authRateLimit";
|
|
14
15
|
import { setAuthAdapter } from "./lib/authAdapter";
|
|
15
16
|
import { mongoAuthAdapter } from "./adapters/mongoAuth";
|
|
@@ -20,6 +21,7 @@ import { connectMongo, connectAuthMongo, connectAppMongo } from "./lib/mongo";
|
|
|
20
21
|
import { connectRedis } from "./lib/redis";
|
|
21
22
|
import { setSessionStore } from "./lib/session";
|
|
22
23
|
import { setCacheStore } from "./middleware/cacheResponse";
|
|
24
|
+
import { maybeAutoRegister } from "./lib/createRoute";
|
|
23
25
|
export const createApp = async (config) => {
|
|
24
26
|
const { routesDir, app: appConfig = {}, auth: authConfig = {}, security: securityConfig = {}, middleware = [], db = {}, } = config;
|
|
25
27
|
const appName = appConfig.name ?? "Bun Core API";
|
|
@@ -39,6 +41,7 @@ export const createApp = async (config) => {
|
|
|
39
41
|
const defaultRole = authConfig.defaultRole;
|
|
40
42
|
const primaryField = authConfig.primaryField ?? "email";
|
|
41
43
|
const emailVerification = authConfig.emailVerification;
|
|
44
|
+
const passwordReset = authConfig.passwordReset;
|
|
42
45
|
const authRateLimit = authConfig.rateLimit;
|
|
43
46
|
const sessionPolicy = authConfig.sessionPolicy ?? {};
|
|
44
47
|
const { sqlite, mongo = "single", redis: enableRedis = true } = db;
|
|
@@ -82,20 +85,31 @@ export const createApp = async (config) => {
|
|
|
82
85
|
else {
|
|
83
86
|
authAdapter = mongoAuthAdapter;
|
|
84
87
|
}
|
|
88
|
+
if (defaultRole && !authAdapter.setRoles) {
|
|
89
|
+
throw new Error(`createApp: "defaultRole" is set to "${defaultRole}" but the auth adapter does not implement setRoles. Add setRoles to your adapter or remove defaultRole.`);
|
|
90
|
+
}
|
|
91
|
+
if (emailVerification && primaryField !== "email") {
|
|
92
|
+
throw new Error(`createApp: "emailVerification" is only supported when primaryField is "email". Either set primaryField to "email" or remove emailVerification.`);
|
|
93
|
+
}
|
|
94
|
+
if (passwordReset && primaryField !== "email") {
|
|
95
|
+
throw new Error(`createApp: "passwordReset" is only supported when primaryField is "email". Either set primaryField to "email" or remove passwordReset.`);
|
|
96
|
+
}
|
|
97
|
+
if (passwordReset && !authAdapter.setPassword) {
|
|
98
|
+
throw new Error(`createApp: "passwordReset" is configured but the auth adapter does not implement setPassword. Add setPassword to your adapter or remove passwordReset.`);
|
|
99
|
+
}
|
|
85
100
|
setAuthAdapter(authAdapter);
|
|
86
101
|
setAppRoles(roles);
|
|
87
102
|
setDefaultRole(defaultRole ?? null);
|
|
88
103
|
setPrimaryField(primaryField);
|
|
89
104
|
setEmailVerificationConfig(emailVerification ?? null);
|
|
90
105
|
setEmailVerificationStore(sessions);
|
|
106
|
+
setPasswordResetConfig(passwordReset ?? null);
|
|
107
|
+
setPasswordResetStore(sessions);
|
|
91
108
|
setAuthRateLimitStore(authRateLimit?.store ?? (enableRedis ? "redis" : "memory"));
|
|
92
109
|
setMaxSessions(sessionPolicy.maxSessions ?? 6);
|
|
93
110
|
setPersistSessionMetadata(sessionPolicy.persistSessionMetadata ?? true);
|
|
94
111
|
setIncludeInactiveSessions(sessionPolicy.includeInactiveSessions ?? false);
|
|
95
112
|
setTrackLastActive(sessionPolicy.trackLastActive ?? false);
|
|
96
|
-
if (defaultRole && !authAdapter.setRoles) {
|
|
97
|
-
throw new Error(`createApp: "defaultRole" is set to "${defaultRole}" but the auth adapter does not implement setRoles. Add setRoles to your adapter or remove defaultRole.`);
|
|
98
|
-
}
|
|
99
113
|
if (oauthProviders)
|
|
100
114
|
initOAuthProviders(oauthProviders);
|
|
101
115
|
const configuredOAuth = getConfiguredOAuthProviders();
|
|
@@ -130,6 +144,42 @@ export const createApp = async (config) => {
|
|
|
130
144
|
for (const mw of middleware)
|
|
131
145
|
app.use(mw);
|
|
132
146
|
setAppName(appName);
|
|
147
|
+
// Schema pre-loading — import shared schema files before routes so registerSchema /
|
|
148
|
+
// registerSchemas calls run first, guaranteeing $ref instead of inline shapes.
|
|
149
|
+
const msConfig = config.modelSchemas;
|
|
150
|
+
if (msConfig) {
|
|
151
|
+
const { paths, registration = "auto" } = typeof msConfig === "string" || Array.isArray(msConfig)
|
|
152
|
+
? { paths: msConfig, registration: "auto" }
|
|
153
|
+
: msConfig;
|
|
154
|
+
const pathArray = paths ? (Array.isArray(paths) ? paths : [paths]) : [];
|
|
155
|
+
for (const entry of pathArray) {
|
|
156
|
+
// Normalize to forward slashes so splitting works on both Windows and Unix.
|
|
157
|
+
const normalized = entry.replaceAll("\\", "/");
|
|
158
|
+
// Split glob patterns: everything before the first wildcard segment is the cwd.
|
|
159
|
+
let cwd;
|
|
160
|
+
let pattern;
|
|
161
|
+
if (!normalized.includes("*")) {
|
|
162
|
+
cwd = normalized;
|
|
163
|
+
pattern = "**/*.ts";
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const parts = normalized.split("/");
|
|
167
|
+
const starIdx = parts.findIndex((p) => p.includes("*"));
|
|
168
|
+
cwd = parts.slice(0, starIdx).join("/");
|
|
169
|
+
pattern = parts.slice(starIdx).join("/");
|
|
170
|
+
}
|
|
171
|
+
const schemaGlob = new Bun.Glob(pattern);
|
|
172
|
+
for await (const file of schemaGlob.scan({ cwd })) {
|
|
173
|
+
const mod = await import(`${cwd}/${file}`);
|
|
174
|
+
if (registration === "auto") {
|
|
175
|
+
for (const [exportName, value] of Object.entries(mod)) {
|
|
176
|
+
maybeAutoRegister(exportName, value);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// "explicit": file imported; any registerSchema/registerSchemas calls inside already ran
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
133
183
|
// Core routes (auth, etc.)
|
|
134
184
|
const coreRoutesDir = import.meta.dir + "/routes";
|
|
135
185
|
const coreGlob = new Bun.Glob("*.ts");
|
|
@@ -144,7 +194,7 @@ export const createApp = async (config) => {
|
|
|
144
194
|
}
|
|
145
195
|
if (enableAuthRoutes) {
|
|
146
196
|
const { createAuthRouter } = await import(`${coreRoutesDir}/auth`);
|
|
147
|
-
app.route("/", createAuthRouter({ primaryField, emailVerification, rateLimit: authRateLimit }));
|
|
197
|
+
app.route("/", createAuthRouter({ primaryField, emailVerification, passwordReset, rateLimit: authRateLimit }));
|
|
148
198
|
}
|
|
149
199
|
if (configuredOAuth.length > 0) {
|
|
150
200
|
app.route("/", createOAuthRouter(configuredOAuth, postOAuthRedirect));
|
|
@@ -173,6 +223,23 @@ export const createApp = async (config) => {
|
|
|
173
223
|
return c.json({ error: "Internal Server Error" }, 500);
|
|
174
224
|
});
|
|
175
225
|
app.notFound((c) => c.json({ error: "Not Found" }, 404));
|
|
226
|
+
app.openAPIRegistry.registerComponent("securitySchemes", "cookieAuth", {
|
|
227
|
+
type: "apiKey",
|
|
228
|
+
in: "cookie",
|
|
229
|
+
name: "token",
|
|
230
|
+
description: "Session cookie set automatically on login/register.",
|
|
231
|
+
});
|
|
232
|
+
app.openAPIRegistry.registerComponent("securitySchemes", "userToken", {
|
|
233
|
+
type: "apiKey",
|
|
234
|
+
in: "header",
|
|
235
|
+
name: "x-user-token",
|
|
236
|
+
description: "JWT session token passed as the x-user-token request header (alternative to the session cookie).",
|
|
237
|
+
});
|
|
238
|
+
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
|
239
|
+
type: "http",
|
|
240
|
+
scheme: "bearer",
|
|
241
|
+
description: "API key passed as Authorization: Bearer <token>. Required on all endpoints unless bearer auth is disabled in CreateAppConfig or the path is in the bypass list.",
|
|
242
|
+
});
|
|
176
243
|
app.doc("/openapi.json", { openapi: "3.0.0", info: { title: appName, version: openApiVersion } });
|
|
177
244
|
app.get("/docs", Scalar({ url: "/openapi.json" }));
|
|
178
245
|
app.get("/sw.js", (c) => c.body("", 200, { "Content-Type": "application/javascript" }));
|