@lastshotlabs/bunshot 0.0.9 → 0.0.10
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 +70 -3
- 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 +18 -2
- package/dist/app.js +18 -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 +4 -7
- package/dist/index.js +4 -5
- package/dist/lib/appConfig.d.ts +9 -0
- package/dist/lib/appConfig.js +5 -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 +76 -1
- package/package.json +38 -8
package/dist/routes/auth.js
CHANGED
|
@@ -9,6 +9,7 @@ import { isLimited, trackAttempt, bustAuthLimit } from "../lib/authRateLimit";
|
|
|
9
9
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
10
10
|
import { createRouter } from "../lib/context";
|
|
11
11
|
import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "../lib/emailVerification";
|
|
12
|
+
import { createResetToken, consumeResetToken } from "../lib/resetPassword";
|
|
12
13
|
import { getUserSessions, deleteSession } from "../lib/session";
|
|
13
14
|
const isProd = process.env.NODE_ENV === "production";
|
|
14
15
|
const TokenResponse = z.object({ token: z.string(), emailVerified: z.boolean().optional() });
|
|
@@ -22,7 +23,7 @@ const cookieOptions = {
|
|
|
22
23
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
23
24
|
};
|
|
24
25
|
const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
|
|
25
|
-
export const createAuthRouter = ({ primaryField, emailVerification, rateLimit }) => {
|
|
26
|
+
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit }) => {
|
|
26
27
|
const router = createRouter();
|
|
27
28
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
28
29
|
const LoginSchema = makeLoginSchema(primaryField);
|
|
@@ -33,6 +34,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
33
34
|
const registerOpts = { windowMs: rateLimit?.register?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.register?.max ?? 5 };
|
|
34
35
|
const verifyOpts = { windowMs: rateLimit?.verifyEmail?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.verifyEmail?.max ?? 10 };
|
|
35
36
|
const resendOpts = { windowMs: rateLimit?.resendVerification?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.resendVerification?.max ?? 3 };
|
|
37
|
+
const forgotOpts = { windowMs: rateLimit?.forgotPassword?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.forgotPassword?.max ?? 5 };
|
|
38
|
+
const resetOpts = { windowMs: rateLimit?.resetPassword?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.resetPassword?.max ?? 10 };
|
|
36
39
|
router.openapi(createRoute({
|
|
37
40
|
method: "post",
|
|
38
41
|
path: "/auth/register",
|
|
@@ -213,6 +216,78 @@ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit })
|
|
|
213
216
|
return c.json({ message: "Verification email sent" }, 200);
|
|
214
217
|
});
|
|
215
218
|
}
|
|
219
|
+
// Password reset routes — only mounted when passwordReset is configured and primaryField is "email"
|
|
220
|
+
if (passwordReset && primaryField === "email") {
|
|
221
|
+
router.openapi(createRoute({
|
|
222
|
+
method: "post",
|
|
223
|
+
path: "/auth/forgot-password",
|
|
224
|
+
tags,
|
|
225
|
+
request: { body: { content: { "application/json": { schema: z.object({ email: z.string().email() }) } } } },
|
|
226
|
+
responses: {
|
|
227
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Reset email sent if address is registered" },
|
|
228
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
229
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
230
|
+
},
|
|
231
|
+
}), async (c) => {
|
|
232
|
+
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
233
|
+
const { email } = c.req.valid("json");
|
|
234
|
+
// Rate-limit by both IP and email to prevent distributed email-bombing
|
|
235
|
+
const ipLimited = await trackAttempt(`forgot:ip:${ip}`, forgotOpts);
|
|
236
|
+
const emailLimited = await trackAttempt(`forgot:email:${email}`, forgotOpts);
|
|
237
|
+
if (ipLimited || emailLimited) {
|
|
238
|
+
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
239
|
+
}
|
|
240
|
+
const adapter = getAuthAdapter();
|
|
241
|
+
const user = await adapter.findByEmail(email);
|
|
242
|
+
// Fire-and-forget: the response does not wait for token creation or email sending,
|
|
243
|
+
// which reduces obvious timing differences between registered and unregistered emails.
|
|
244
|
+
const msg = { message: "If that email is registered, a password reset link has been sent." };
|
|
245
|
+
if (user) {
|
|
246
|
+
void (async () => {
|
|
247
|
+
try {
|
|
248
|
+
const token = await createResetToken(user.id, email);
|
|
249
|
+
await passwordReset.onSend(email, token);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
console.error("Failed to send password reset email:", err);
|
|
253
|
+
}
|
|
254
|
+
})();
|
|
255
|
+
}
|
|
256
|
+
return c.json(msg, 200);
|
|
257
|
+
});
|
|
258
|
+
router.openapi(createRoute({
|
|
259
|
+
method: "post",
|
|
260
|
+
path: "/auth/reset-password",
|
|
261
|
+
tags,
|
|
262
|
+
request: { body: { content: { "application/json": { schema: z.object({ token: z.string(), password: z.string().min(8) }) } } } },
|
|
263
|
+
responses: {
|
|
264
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password reset successfully" },
|
|
265
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error or invalid/expired token" },
|
|
266
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
267
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
|
|
268
|
+
},
|
|
269
|
+
}), async (c) => {
|
|
270
|
+
const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
|
|
271
|
+
if (await trackAttempt(`reset:${ip}`, resetOpts)) {
|
|
272
|
+
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
273
|
+
}
|
|
274
|
+
const { token, password } = c.req.valid("json");
|
|
275
|
+
// consumeResetToken atomically gets and deletes — prevents concurrent replay
|
|
276
|
+
const entry = await consumeResetToken(token);
|
|
277
|
+
if (!entry)
|
|
278
|
+
return c.json({ error: "Invalid or expired reset token" }, 400);
|
|
279
|
+
const adapter = getAuthAdapter();
|
|
280
|
+
if (!adapter.setPassword) {
|
|
281
|
+
return c.json({ error: "Auth adapter does not support setPassword" }, 501);
|
|
282
|
+
}
|
|
283
|
+
const passwordHash = await Bun.password.hash(password);
|
|
284
|
+
await adapter.setPassword(entry.userId, passwordHash);
|
|
285
|
+
// Revoke all sessions so stolen JWTs can't stay valid after a reset
|
|
286
|
+
const sessions = await getUserSessions(entry.userId);
|
|
287
|
+
await Promise.all(sessions.map((s) => deleteSession(s.sessionId)));
|
|
288
|
+
return c.json({ message: "Password reset successfully" }, 200);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
216
291
|
// ---------------------------------------------------------------------------
|
|
217
292
|
// Session management
|
|
218
293
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lastshotlabs/bunshot",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Batteries-included Bun + Hono API framework — auth, sessions, rate limiting, WebSocket, queues, and OpenAPI docs out of the box",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,6 +22,18 @@
|
|
|
22
22
|
".": {
|
|
23
23
|
"import": "./dist/index.js",
|
|
24
24
|
"types": "./dist/index.d.ts"
|
|
25
|
+
},
|
|
26
|
+
"./mongo": {
|
|
27
|
+
"import": "./dist/entrypoints/mongo.js",
|
|
28
|
+
"types": "./dist/entrypoints/mongo.d.ts"
|
|
29
|
+
},
|
|
30
|
+
"./redis": {
|
|
31
|
+
"import": "./dist/entrypoints/redis.js",
|
|
32
|
+
"types": "./dist/entrypoints/redis.d.ts"
|
|
33
|
+
},
|
|
34
|
+
"./queue": {
|
|
35
|
+
"import": "./dist/entrypoints/queue.js",
|
|
36
|
+
"types": "./dist/entrypoints/queue.d.ts"
|
|
25
37
|
}
|
|
26
38
|
},
|
|
27
39
|
"bin": {
|
|
@@ -41,17 +53,35 @@
|
|
|
41
53
|
"@hono/zod-openapi": "1.2.2",
|
|
42
54
|
"@scalar/hono-api-reference": "0.10.0",
|
|
43
55
|
"arctic": "^3.7.0",
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
56
|
+
"jose": "6.2.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"hono": ">=4.12 <5",
|
|
60
|
+
"zod": ">=4.0 <5",
|
|
61
|
+
"mongoose": ">=9.0 <10",
|
|
62
|
+
"ioredis": ">=5.0 <6",
|
|
63
|
+
"bullmq": ">=5.0 <6"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"mongoose": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"ioredis": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"bullmq": {
|
|
73
|
+
"optional": true
|
|
74
|
+
}
|
|
50
75
|
},
|
|
51
76
|
"devDependencies": {
|
|
52
77
|
"@types/bun": "1.3.10",
|
|
53
78
|
"tsc-alias": "^1.8.16",
|
|
54
|
-
"typescript": "^5.9.3"
|
|
79
|
+
"typescript": "^5.9.3",
|
|
80
|
+
"hono": ">=4.12",
|
|
81
|
+
"zod": ">=4.0",
|
|
82
|
+
"mongoose": "9.2.4",
|
|
83
|
+
"ioredis": "5.10.0",
|
|
84
|
+
"bullmq": "^5.70.4"
|
|
55
85
|
},
|
|
56
86
|
"publishConfig": {
|
|
57
87
|
"access": "public"
|