@lastshotlabs/bunshot 0.0.13 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +2816 -1747
  2. package/dist/adapters/memoryAuth.d.ts +7 -0
  3. package/dist/adapters/memoryAuth.js +177 -2
  4. package/dist/adapters/mongoAuth.js +94 -0
  5. package/dist/adapters/sqliteAuth.d.ts +9 -0
  6. package/dist/adapters/sqliteAuth.js +190 -2
  7. package/dist/app.d.ts +120 -2
  8. package/dist/app.js +104 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +24 -8
  12. package/dist/index.js +15 -5
  13. package/dist/lib/appConfig.d.ts +81 -0
  14. package/dist/lib/appConfig.js +30 -0
  15. package/dist/lib/authAdapter.d.ts +54 -0
  16. package/dist/lib/authRateLimit.d.ts +2 -0
  17. package/dist/lib/authRateLimit.js +4 -0
  18. package/dist/lib/clientIp.d.ts +14 -0
  19. package/dist/lib/clientIp.js +52 -0
  20. package/dist/lib/constants.d.ts +4 -0
  21. package/dist/lib/constants.js +4 -0
  22. package/dist/lib/context.d.ts +2 -0
  23. package/dist/lib/createDtoMapper.d.ts +33 -0
  24. package/dist/lib/createDtoMapper.js +69 -0
  25. package/dist/lib/crypto.d.ts +11 -0
  26. package/dist/lib/crypto.js +22 -0
  27. package/dist/lib/emailVerification.d.ts +4 -0
  28. package/dist/lib/emailVerification.js +20 -12
  29. package/dist/lib/jwt.d.ts +1 -1
  30. package/dist/lib/jwt.js +19 -6
  31. package/dist/lib/mfaChallenge.d.ts +42 -0
  32. package/dist/lib/mfaChallenge.js +293 -0
  33. package/dist/lib/oauth.d.ts +14 -1
  34. package/dist/lib/oauth.js +19 -1
  35. package/dist/lib/oauthCode.d.ts +15 -0
  36. package/dist/lib/oauthCode.js +90 -0
  37. package/dist/lib/queue.d.ts +33 -0
  38. package/dist/lib/queue.js +98 -0
  39. package/dist/lib/resetPassword.js +12 -16
  40. package/dist/lib/roles.d.ts +4 -0
  41. package/dist/lib/roles.js +27 -0
  42. package/dist/lib/session.d.ts +12 -0
  43. package/dist/lib/session.js +165 -5
  44. package/dist/lib/tenant.d.ts +15 -0
  45. package/dist/lib/tenant.js +65 -0
  46. package/dist/lib/ws.js +5 -1
  47. package/dist/lib/zodToMongoose.d.ts +38 -0
  48. package/dist/lib/zodToMongoose.js +84 -0
  49. package/dist/middleware/bearerAuth.js +4 -3
  50. package/dist/middleware/botProtection.js +2 -2
  51. package/dist/middleware/cacheResponse.d.ts +1 -0
  52. package/dist/middleware/cacheResponse.js +18 -3
  53. package/dist/middleware/cors.d.ts +2 -0
  54. package/dist/middleware/cors.js +22 -8
  55. package/dist/middleware/csrf.d.ts +18 -0
  56. package/dist/middleware/csrf.js +115 -0
  57. package/dist/middleware/rateLimit.d.ts +2 -1
  58. package/dist/middleware/rateLimit.js +7 -5
  59. package/dist/middleware/requireRole.d.ts +14 -3
  60. package/dist/middleware/requireRole.js +46 -6
  61. package/dist/middleware/tenant.d.ts +5 -0
  62. package/dist/middleware/tenant.js +116 -0
  63. package/dist/models/AuthUser.d.ts +17 -0
  64. package/dist/models/AuthUser.js +17 -0
  65. package/dist/models/TenantRole.d.ts +15 -0
  66. package/dist/models/TenantRole.js +23 -0
  67. package/dist/routes/auth.d.ts +5 -3
  68. package/dist/routes/auth.js +173 -30
  69. package/dist/routes/jobs.d.ts +2 -0
  70. package/dist/routes/jobs.js +270 -0
  71. package/dist/routes/mfa.d.ts +5 -0
  72. package/dist/routes/mfa.js +616 -0
  73. package/dist/routes/oauth.js +378 -23
  74. package/dist/schemas/auth.d.ts +2 -0
  75. package/dist/schemas/auth.js +22 -1
  76. package/dist/server.d.ts +6 -0
  77. package/dist/server.js +19 -3
  78. package/dist/services/auth.d.ts +18 -5
  79. package/dist/services/auth.js +112 -18
  80. package/dist/services/mfa.d.ts +84 -0
  81. package/dist/services/mfa.js +543 -0
  82. package/dist/ws/index.js +3 -2
  83. package/docs/sections/adding-middleware/full.md +35 -0
  84. package/docs/sections/adding-models/full.md +125 -0
  85. package/docs/sections/adding-models/overview.md +13 -0
  86. package/docs/sections/adding-routes/full.md +182 -0
  87. package/docs/sections/adding-routes/overview.md +23 -0
  88. package/docs/sections/auth-flow/full.md +634 -0
  89. package/docs/sections/auth-flow/overview.md +10 -0
  90. package/docs/sections/cli/full.md +30 -0
  91. package/docs/sections/configuration/full.md +155 -0
  92. package/docs/sections/configuration/overview.md +17 -0
  93. package/docs/sections/configuration-example/full.md +117 -0
  94. package/docs/sections/configuration-example/overview.md +30 -0
  95. package/docs/sections/documentation/full.md +171 -0
  96. package/docs/sections/environment-variables/full.md +55 -0
  97. package/docs/sections/exports/full.md +92 -0
  98. package/docs/sections/extending-context/full.md +59 -0
  99. package/docs/sections/header.md +3 -0
  100. package/docs/sections/installation/full.md +6 -0
  101. package/docs/sections/jobs/full.md +140 -0
  102. package/docs/sections/jobs/overview.md +15 -0
  103. package/docs/sections/mongodb-connections/full.md +45 -0
  104. package/docs/sections/mongodb-connections/overview.md +7 -0
  105. package/docs/sections/multi-tenancy/full.md +66 -0
  106. package/docs/sections/multi-tenancy/overview.md +15 -0
  107. package/docs/sections/oauth/full.md +189 -0
  108. package/docs/sections/oauth/overview.md +16 -0
  109. package/docs/sections/package-development/full.md +7 -0
  110. package/docs/sections/peer-dependencies/full.md +47 -0
  111. package/docs/sections/quick-start/full.md +43 -0
  112. package/docs/sections/response-caching/full.md +117 -0
  113. package/docs/sections/response-caching/overview.md +13 -0
  114. package/docs/sections/roles/full.md +136 -0
  115. package/docs/sections/roles/overview.md +12 -0
  116. package/docs/sections/running-without-redis/full.md +16 -0
  117. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  118. package/docs/sections/stack/full.md +10 -0
  119. package/docs/sections/websocket/full.md +101 -0
  120. package/docs/sections/websocket/overview.md +5 -0
  121. package/docs/sections/websocket-rooms/full.md +97 -0
  122. package/docs/sections/websocket-rooms/overview.md +5 -0
  123. package/package.json +30 -9
@@ -2,34 +2,40 @@ import { createRoute, withSecurity } from "../lib/createRoute";
2
2
  import { z } from "zod";
3
3
  import { setCookie, getCookie, deleteCookie } from "hono/cookie";
4
4
  import * as AuthService from "../services/auth";
5
- import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
6
- import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "../lib/constants";
5
+ import { makeRegisterSchema, makeLoginSchema, resetPasswordSchema } from "../schemas/auth";
6
+ import { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN } from "../lib/constants";
7
7
  import { userAuth } from "../middleware/userAuth";
8
8
  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
12
  import { createResetToken, consumeResetToken } from "../lib/resetPassword";
13
- import { getUserSessions, deleteSession } from "../lib/session";
13
+ import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
14
+ import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
15
+ import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
16
+ import { getClientIp } from "../lib/clientIp";
14
17
  const isProd = process.env.NODE_ENV === "production";
15
18
  const TokenResponse = z.object({
16
- token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie."),
19
+ token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
17
20
  userId: z.string().describe("Unique user ID."),
18
21
  email: z.string().optional().describe("User's email address (present when primaryField is 'email')."),
19
22
  emailVerified: z.boolean().optional().describe("Whether the email address has been verified (present when emailVerification is configured)."),
20
23
  googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked to this user."),
24
+ refreshToken: z.string().optional().describe("Refresh token (present when refreshTokens is configured). Also set as an HttpOnly cookie."),
25
+ mfaRequired: z.boolean().optional().describe("When true, complete MFA via POST /auth/mfa/verify before accessing the API."),
26
+ mfaToken: z.string().optional().describe("MFA challenge token. Pass to POST /auth/mfa/verify with a TOTP or recovery code."),
27
+ mfaMethods: z.array(z.string()).optional().describe("Available MFA methods when mfaRequired is true (e.g., 'totp', 'emailOtp')."),
21
28
  }).openapi("TokenResponse");
22
29
  const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
23
30
  const tags = ["Auth"];
24
- const cookieOptions = {
31
+ const cookieOptions = (maxAge) => ({
25
32
  httpOnly: true,
26
33
  secure: isProd,
27
34
  sameSite: "Lax",
28
35
  path: "/",
29
- maxAge: 60 * 60 * 24 * 7, // 7 days
30
- };
31
- const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
32
- export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit }) => {
36
+ maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
37
+ });
38
+ export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
33
39
  const router = createRouter();
34
40
  const RegisterSchema = makeRegisterSchema(primaryField);
35
41
  const LoginSchema = makeLoginSchema(primaryField);
@@ -56,7 +62,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
56
62
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
57
63
  },
58
64
  }), async (c) => {
59
- const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
65
+ const ip = getClientIp(c);
60
66
  if (await trackAttempt(`register:${ip}`, registerOpts)) {
61
67
  return c.json({ error: "Too many registration attempts. Try again later." }, 429);
62
68
  }
@@ -67,7 +73,12 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
67
73
  userAgent: c.req.header("user-agent") ?? undefined,
68
74
  };
69
75
  const result = await AuthService.register(identifier, body.password, metadata);
70
- setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
76
+ setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
77
+ if (result.refreshToken) {
78
+ setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
79
+ }
80
+ if (getCsrfEnabled())
81
+ refreshCsrfToken(c);
71
82
  return c.json(result, 201);
72
83
  });
73
84
  router.openapi(createRoute({
@@ -91,13 +102,20 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
91
102
  return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
92
103
  }
93
104
  const metadata = {
94
- ipAddress: clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")),
105
+ ipAddress: getClientIp(c),
95
106
  userAgent: c.req.header("user-agent") ?? undefined,
96
107
  };
97
108
  try {
98
109
  const result = await AuthService.login(identifier, body.password, metadata);
99
110
  await bustAuthLimit(limitKey); // success — clear failure count
100
- setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
111
+ if (!result.mfaRequired) {
112
+ setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
113
+ if (result.refreshToken) {
114
+ setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
115
+ }
116
+ if (getCsrfEnabled())
117
+ refreshCsrfToken(c);
118
+ }
101
119
  return c.json(result, 200);
102
120
  }
103
121
  catch (err) {
@@ -135,6 +153,74 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
135
153
  const googleLinked = user?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
136
154
  return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
137
155
  });
156
+ // ---------------------------------------------------------------------------
157
+ // Account deletion
158
+ // ---------------------------------------------------------------------------
159
+ const deleteAccountOpts = { windowMs: rateLimit?.deleteAccount?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.deleteAccount?.max ?? 3 };
160
+ router.openapi(withSecurity(createRoute({
161
+ method: "delete",
162
+ path: "/auth/me",
163
+ summary: "Delete account",
164
+ description: "Permanently deletes the authenticated user's account. Requires password confirmation for credential accounts. MFA is not required — the password serves as the identity check. Revokes all active sessions.",
165
+ tags,
166
+ request: {
167
+ body: {
168
+ content: {
169
+ "application/json": {
170
+ schema: z.object({
171
+ password: z.string().optional().describe("Current password. Required for credential accounts, optional for OAuth-only accounts."),
172
+ }),
173
+ },
174
+ },
175
+ description: "Password confirmation.",
176
+ },
177
+ },
178
+ responses: {
179
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deleted." },
180
+ 202: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deletion has been scheduled." },
181
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Password is required for credential accounts." },
182
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid password or no valid session." },
183
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many deletion attempts. Try again later." },
184
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support deleteUser." },
185
+ },
186
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
187
+ const authUserId = c.get("authUserId");
188
+ if (await trackAttempt(`deleteaccount:${authUserId}`, deleteAccountOpts)) {
189
+ return c.json({ error: "Too many deletion attempts. Try again later." }, 429);
190
+ }
191
+ const adapter = getAuthAdapter();
192
+ if (!adapter.deleteUser) {
193
+ return c.json({ error: "Auth adapter does not support deleteUser" }, 501);
194
+ }
195
+ const { password } = c.req.valid("json");
196
+ // Verify password for credential accounts
197
+ if (password) {
198
+ const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
199
+ const email = user?.email;
200
+ if (email) {
201
+ const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
202
+ const found = await findFn(email);
203
+ if (found && !(await Bun.password.verify(password, found.passwordHash))) {
204
+ return c.json({ error: "Invalid password" }, 401);
205
+ }
206
+ }
207
+ }
208
+ else if (adapter.hasPassword && await adapter.hasPassword(authUserId)) {
209
+ return c.json({ error: "Password is required to delete a credential account" }, 400);
210
+ }
211
+ // Call onBeforeDelete hook
212
+ if (accountDeletion?.onBeforeDelete) {
213
+ await accountDeletion.onBeforeDelete(authUserId);
214
+ }
215
+ // Synchronous deletion (default)
216
+ await deleteUserSessions(authUserId);
217
+ await adapter.deleteUser(authUserId);
218
+ if (accountDeletion?.onAfterDelete) {
219
+ await accountDeletion.onAfterDelete(authUserId);
220
+ }
221
+ deleteCookie(c, COOKIE_TOKEN, { path: "/" });
222
+ return c.json({ message: "Account deleted" }, 200);
223
+ });
138
224
  router.use("/auth/set-password", userAuth);
139
225
  router.openapi(withSecurity(createRoute({
140
226
  method: "post",
@@ -173,6 +259,9 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
173
259
  const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
174
260
  await AuthService.logout(token);
175
261
  deleteCookie(c, COOKIE_TOKEN, { path: "/" });
262
+ deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
263
+ if (getCsrfEnabled())
264
+ clearCsrfToken(c);
176
265
  return c.json({ message: "Logged out" }, 200);
177
266
  });
178
267
  // Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
@@ -190,7 +279,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
190
279
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
191
280
  },
192
281
  }), async (c) => {
193
- const ip = c.req.header("x-forwarded-for") ?? "unknown";
282
+ const ip = getClientIp(c);
194
283
  if (await trackAttempt(`verify:${ip}`, verifyOpts)) {
195
284
  return c.json({ error: "Too many verification attempts. Try again later." }, 429);
196
285
  }
@@ -204,37 +293,43 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
204
293
  await deleteVerificationToken(token);
205
294
  return c.json({ message: "Email verified" }, 200);
206
295
  });
207
- router.use("/auth/resend-verification", userAuth);
208
- router.openapi(withSecurity(createRoute({
296
+ router.openapi(createRoute({
209
297
  method: "post",
210
298
  path: "/auth/resend-verification",
211
299
  summary: "Resend verification email",
212
- description: "Sends a new verification email to the authenticated user's address. Returns 400 if already verified. Rate-limited per user. Requires a valid session.",
300
+ description: "Authenticates with credentials and sends a new verification email. Returns 400 if already verified. Rate-limited per identifier. Does not require a session.",
213
301
  tags,
302
+ request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials to identify the account." } },
214
303
  responses: {
215
304
  200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
216
305
  400: { content: { "application/json": { schema: ErrorResponse } }, description: "Email is already verified, or no email address on file." },
217
- 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
218
- 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this user. Try again later." },
306
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
307
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this identifier. Try again later." },
219
308
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
220
309
  },
221
- }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
310
+ }), async (c) => {
222
311
  const adapter = getAuthAdapter();
223
312
  if (!adapter.getEmailVerified || !adapter.getUser) {
224
313
  return c.json({ error: "Auth adapter does not support email verification" }, 501);
225
314
  }
226
- const authUserId = c.get("authUserId");
227
- if (await trackAttempt(`resend:${authUserId}`, resendOpts)) {
315
+ const body = c.req.valid("json");
316
+ const identifier = body[primaryField];
317
+ if (await trackAttempt(`resend:${identifier}`, resendOpts)) {
228
318
  return c.json({ error: "Too many resend attempts. Try again later." }, 429);
229
319
  }
230
- const alreadyVerified = await adapter.getEmailVerified(authUserId);
320
+ const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
321
+ const user = await findFn(identifier);
322
+ if (!user || !(await Bun.password.verify(body.password, user.passwordHash))) {
323
+ return c.json({ error: "Invalid credentials" }, 401);
324
+ }
325
+ const alreadyVerified = await adapter.getEmailVerified(user.id);
231
326
  if (alreadyVerified)
232
327
  return c.json({ error: "Email already verified" }, 400);
233
- const user = await adapter.getUser(authUserId);
234
- if (!user?.email)
328
+ const fullUser = await adapter.getUser(user.id);
329
+ if (!fullUser?.email)
235
330
  return c.json({ error: "No email address on file" }, 400);
236
- const verificationToken = await createVerificationToken(authUserId, user.email);
237
- await emailVerification.onSend(user.email, verificationToken);
331
+ const verificationToken = await createVerificationToken(user.id, fullUser.email);
332
+ await emailVerification.onSend(fullUser.email, verificationToken);
238
333
  return c.json({ message: "Verification email sent" }, 200);
239
334
  });
240
335
  }
@@ -253,7 +348,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
253
348
  429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
254
349
  },
255
350
  }), async (c) => {
256
- const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
351
+ const ip = getClientIp(c);
257
352
  const { email } = c.req.valid("json");
258
353
  // Rate-limit by both IP and email to prevent distributed email-bombing
259
354
  const ipLimited = await trackAttempt(`forgot:ip:${ip}`, forgotOpts);
@@ -291,7 +386,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
291
386
  "application/json": {
292
387
  schema: z.object({
293
388
  token: z.string().describe("Single-use reset token received via email."),
294
- password: z.string().min(8).describe("New password. Minimum 8 characters."),
389
+ password: resetPasswordSchema().describe("New password."),
295
390
  }),
296
391
  },
297
392
  },
@@ -305,7 +400,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
305
400
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
306
401
  },
307
402
  }), async (c) => {
308
- const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
403
+ const ip = getClientIp(c);
309
404
  if (await trackAttempt(`reset:${ip}`, resetOpts)) {
310
405
  return c.json({ error: "Too many attempts. Try again later." }, 429);
311
406
  }
@@ -327,6 +422,54 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
327
422
  });
328
423
  }
329
424
  // ---------------------------------------------------------------------------
425
+ // Refresh token route — only mounted when refreshTokens is configured
426
+ // ---------------------------------------------------------------------------
427
+ if (refreshTokens) {
428
+ const RefreshResponse = z.object({
429
+ token: z.string().describe("New short-lived JWT access token."),
430
+ refreshToken: z.string().describe("New refresh token (rotation). The previous token is valid for a short grace window."),
431
+ userId: z.string().describe("Unique user ID."),
432
+ }).openapi("RefreshResponse");
433
+ router.openapi(createRoute({
434
+ method: "post",
435
+ path: "/auth/refresh",
436
+ summary: "Refresh access token",
437
+ description: "Exchanges a valid refresh token for a new access token and rotated refresh token. The old refresh token remains valid for a short grace window to handle network drops. If a previously rotated token is reused after the grace window, the entire session is invalidated (token theft detection).",
438
+ tags,
439
+ request: {
440
+ body: {
441
+ content: {
442
+ "application/json": {
443
+ schema: z.object({
444
+ refreshToken: z.string().optional().describe("Refresh token. Can also be sent via the refresh_token cookie or x-refresh-token header."),
445
+ }),
446
+ },
447
+ },
448
+ description: "Refresh token (optional if sent via cookie or header).",
449
+ },
450
+ },
451
+ responses: {
452
+ 200: { content: { "application/json": { schema: RefreshResponse } }, description: "New access and refresh tokens." },
453
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired refresh token, or session invalidated due to token theft detection." },
454
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many refresh attempts. Try again later." },
455
+ },
456
+ }), async (c) => {
457
+ const ip = getClientIp(c);
458
+ if (await trackAttempt(`refresh:ip:${ip}`, { max: 30, windowMs: 60_000 })) {
459
+ return c.json({ error: "Too many refresh attempts. Try again later." }, 429);
460
+ }
461
+ const body = c.req.valid("json");
462
+ const rt = body.refreshToken ?? getCookie(c, COOKIE_REFRESH_TOKEN) ?? c.req.header(HEADER_REFRESH_TOKEN) ?? null;
463
+ if (!rt) {
464
+ return c.json({ error: "Refresh token is required" }, 401);
465
+ }
466
+ const result = await AuthService.refresh(rt);
467
+ setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(getAccessTokenExpiry()));
468
+ setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
469
+ return c.json(result, 200);
470
+ });
471
+ }
472
+ // ---------------------------------------------------------------------------
330
473
  // Session management
331
474
  // ---------------------------------------------------------------------------
332
475
  const SessionInfoSchema = z.object({
@@ -0,0 +1,2 @@
1
+ import type { JobsConfig } from "../app";
2
+ export declare const createJobsRouter: (config: JobsConfig) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
@@ -0,0 +1,270 @@
1
+ import { createRoute, withSecurity } from "../lib/createRoute";
2
+ import { z } from "zod";
3
+ import { createRouter } from "../lib/context";
4
+ import { userAuth } from "../middleware/userAuth";
5
+ import { requireRole } from "../middleware/requireRole";
6
+ import { createQueue } from "../lib/queue";
7
+ const tags = ["Jobs"];
8
+ const ErrorResponse = z.object({ error: z.string() });
9
+ const JobStatusResponse = z.object({
10
+ id: z.string().describe("Job ID."),
11
+ state: z.string().describe("Job state: waiting, active, completed, failed, delayed, paused."),
12
+ progress: z.union([z.number(), z.record(z.string(), z.unknown())]).describe("Job progress."),
13
+ result: z.unknown().optional().describe("Job result (when completed)."),
14
+ failedReason: z.string().optional().describe("Failure reason (when failed)."),
15
+ attemptsMade: z.number().describe("Number of attempts made."),
16
+ timestamp: z.number().describe("Unix timestamp (ms) when the job was created."),
17
+ finishedOn: z.number().optional().describe("Unix timestamp (ms) when the job finished."),
18
+ }).openapi("JobStatus");
19
+ export const createJobsRouter = (config) => {
20
+ const router = createRouter();
21
+ const allowedQueues = new Set(config.allowedQueues ?? []);
22
+ const authConfig = config.auth ?? "none";
23
+ const scopeToUser = config.scopeToUser ?? false;
24
+ // Determine if userAuth is involved (for scopeToUser and OpenAPI security schemes)
25
+ const hasUserAuth = authConfig === "userAuth" || Array.isArray(authConfig);
26
+ // Apply middleware based on config
27
+ if (authConfig === "userAuth") {
28
+ router.use("/jobs/*", userAuth);
29
+ if (config.roles?.length) {
30
+ router.use("/jobs/*", requireRole(...config.roles));
31
+ }
32
+ }
33
+ else if (Array.isArray(authConfig)) {
34
+ for (const mw of authConfig) {
35
+ router.use("/jobs/*", mw);
36
+ }
37
+ }
38
+ // "none" requires no middleware
39
+ function isQueueAllowed(queueName) {
40
+ return allowedQueues.has(queueName);
41
+ }
42
+ /** Determine OpenAPI security for a route */
43
+ function applyRouteSecurity(route) {
44
+ if (authConfig === "userAuth") {
45
+ return withSecurity(route, { cookieAuth: [] }, { userToken: [] });
46
+ }
47
+ if (Array.isArray(authConfig)) {
48
+ // Custom middleware — mark as cookieAuth/userToken if it likely includes userAuth
49
+ return withSecurity(route, { cookieAuth: [] }, { userToken: [] });
50
+ }
51
+ return route;
52
+ }
53
+ /** Map a BullMQ job to the response shape */
54
+ async function jobToResponse(job) {
55
+ const state = await job.getState();
56
+ return {
57
+ id: job.id,
58
+ state,
59
+ progress: job.progress,
60
+ result: job.returnvalue,
61
+ failedReason: job.failedReason ?? undefined,
62
+ attemptsMade: job.attemptsMade,
63
+ timestamp: job.timestamp,
64
+ finishedOn: job.finishedOn ?? undefined,
65
+ };
66
+ }
67
+ // ─── List available queues ──────────────────────────────────────────────
68
+ const listQueuesRoute = createRoute({
69
+ method: "get",
70
+ path: "/jobs",
71
+ summary: "List available queues",
72
+ description: "Returns the list of queue names exposed via the API.",
73
+ tags,
74
+ responses: {
75
+ 200: {
76
+ content: {
77
+ "application/json": {
78
+ schema: z.object({
79
+ queues: z.array(z.string()).describe("Available queue names."),
80
+ }),
81
+ },
82
+ },
83
+ description: "Available queues.",
84
+ },
85
+ },
86
+ });
87
+ router.openapi(applyRouteSecurity(listQueuesRoute), async (c) => {
88
+ return c.json({ queues: [...allowedQueues] }, 200);
89
+ });
90
+ // ─── List jobs in a queue ─────────────────────────────────────────────
91
+ const listJobsRoute = createRoute({
92
+ method: "get",
93
+ path: "/jobs/{queue}",
94
+ summary: "List jobs in a queue",
95
+ description: "Returns a paginated list of jobs in a queue, optionally filtered by state.",
96
+ tags,
97
+ request: {
98
+ params: z.object({
99
+ queue: z.string().describe("Queue name."),
100
+ }),
101
+ query: z.object({
102
+ state: z.enum(["waiting", "active", "completed", "failed", "delayed", "paused"]).optional().describe("Filter by job state."),
103
+ start: z.string().optional().describe("Start index. Default: 0."),
104
+ end: z.string().optional().describe("End index. Default: 19."),
105
+ }),
106
+ },
107
+ responses: {
108
+ 200: {
109
+ content: {
110
+ "application/json": {
111
+ schema: z.object({
112
+ jobs: z.array(JobStatusResponse),
113
+ total: z.number().describe("Total jobs matching the filter."),
114
+ }),
115
+ },
116
+ },
117
+ description: "Jobs list.",
118
+ },
119
+ 403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not allowed." },
120
+ },
121
+ });
122
+ router.openapi(applyRouteSecurity(listJobsRoute), async (c) => {
123
+ const { queue: queueName } = c.req.valid("param");
124
+ if (!isQueueAllowed(queueName)) {
125
+ return c.json({ error: "Queue not allowed" }, 403);
126
+ }
127
+ const { state, start: startStr, end: endStr } = c.req.valid("query");
128
+ const start = startStr ? parseInt(startStr) : 0;
129
+ const end = endStr ? parseInt(endStr) : 19;
130
+ const queue = createQueue(queueName);
131
+ // Get jobs by state or all jobs
132
+ const stateFilter = state ?? "waiting";
133
+ const jobs = await queue.getJobs([stateFilter], start, end);
134
+ // Get total count for the filtered state
135
+ const counts = await queue.getJobCounts(stateFilter);
136
+ const total = counts[stateFilter] ?? 0;
137
+ // Optionally filter by userId
138
+ let filteredJobs = jobs;
139
+ if (scopeToUser && hasUserAuth) {
140
+ const userId = c.get("authUserId");
141
+ filteredJobs = jobs.filter((job) => job.data?.userId === userId);
142
+ }
143
+ const result = await Promise.all(filteredJobs.map(jobToResponse));
144
+ return c.json({ jobs: result, total }, 200);
145
+ });
146
+ // ─── Get job status ─────────────────────────────────────────────────────
147
+ const getJobRoute = createRoute({
148
+ method: "get",
149
+ path: "/jobs/{queue}/{id}",
150
+ summary: "Get job status",
151
+ description: "Returns the current state, progress, result, or failure reason for a job.",
152
+ tags,
153
+ request: {
154
+ params: z.object({
155
+ queue: z.string().describe("Queue name."),
156
+ id: z.string().describe("Job ID."),
157
+ }),
158
+ },
159
+ responses: {
160
+ 200: { content: { "application/json": { schema: JobStatusResponse } }, description: "Job status." },
161
+ 403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not in allowedQueues." },
162
+ 404: { content: { "application/json": { schema: ErrorResponse } }, description: "Job not found." },
163
+ },
164
+ });
165
+ router.openapi(applyRouteSecurity(getJobRoute), async (c) => {
166
+ const { queue: queueName, id } = c.req.valid("param");
167
+ if (!isQueueAllowed(queueName)) {
168
+ return c.json({ error: "Queue not allowed" }, 403);
169
+ }
170
+ const queue = createQueue(queueName);
171
+ const job = await queue.getJob(id);
172
+ if (!job)
173
+ return c.json({ error: "Job not found" }, 404);
174
+ // Scope to user if configured
175
+ if (scopeToUser && hasUserAuth) {
176
+ const userId = c.get("authUserId");
177
+ if (job.data?.userId !== userId) {
178
+ return c.json({ error: "Job not found" }, 404);
179
+ }
180
+ }
181
+ return c.json(await jobToResponse(job), 200);
182
+ });
183
+ // ─── Get job logs ───────────────────────────────────────────────────────
184
+ const getJobLogsRoute = createRoute({
185
+ method: "get",
186
+ path: "/jobs/{queue}/{id}/logs",
187
+ summary: "Get job logs",
188
+ description: "Returns logs for a specific job.",
189
+ tags,
190
+ request: {
191
+ params: z.object({
192
+ queue: z.string().describe("Queue name."),
193
+ id: z.string().describe("Job ID."),
194
+ }),
195
+ },
196
+ responses: {
197
+ 200: {
198
+ content: {
199
+ "application/json": {
200
+ schema: z.object({
201
+ logs: z.array(z.string()).describe("Log entries."),
202
+ count: z.number().describe("Total log count."),
203
+ }),
204
+ },
205
+ },
206
+ description: "Job logs.",
207
+ },
208
+ 403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not allowed." },
209
+ 404: { content: { "application/json": { schema: ErrorResponse } }, description: "Job not found." },
210
+ },
211
+ });
212
+ router.openapi(applyRouteSecurity(getJobLogsRoute), async (c) => {
213
+ const { queue: queueName, id } = c.req.valid("param");
214
+ if (!isQueueAllowed(queueName)) {
215
+ return c.json({ error: "Queue not allowed" }, 403);
216
+ }
217
+ const queue = createQueue(queueName);
218
+ const job = await queue.getJob(id);
219
+ if (!job)
220
+ return c.json({ error: "Job not found" }, 404);
221
+ const { logs, count } = await queue.getJobLogs(id);
222
+ return c.json({ logs, count }, 200);
223
+ });
224
+ // ─── Dead letter queue ────────────────────────────────────────────────
225
+ const getDlqRoute = createRoute({
226
+ method: "get",
227
+ path: "/jobs/{queue}/dead-letters",
228
+ summary: "List dead letter queue jobs",
229
+ description: "Returns paginated list of jobs in the dead letter queue for a given source queue.",
230
+ tags,
231
+ request: {
232
+ params: z.object({ queue: z.string().describe("Source queue name (DLQ name is {queue}-dlq).") }),
233
+ query: z.object({
234
+ start: z.string().optional().describe("Start index. Default: 0."),
235
+ end: z.string().optional().describe("End index. Default: 19."),
236
+ }),
237
+ },
238
+ responses: {
239
+ 200: {
240
+ content: {
241
+ "application/json": {
242
+ schema: z.object({
243
+ jobs: z.array(JobStatusResponse),
244
+ total: z.number().describe("Total jobs in DLQ."),
245
+ }),
246
+ },
247
+ },
248
+ description: "DLQ jobs.",
249
+ },
250
+ 403: { content: { "application/json": { schema: ErrorResponse } }, description: "Queue not allowed." },
251
+ },
252
+ });
253
+ router.openapi(applyRouteSecurity(getDlqRoute), async (c) => {
254
+ const { queue: queueName } = c.req.valid("param");
255
+ if (!isQueueAllowed(queueName)) {
256
+ return c.json({ error: "Queue not allowed" }, 403);
257
+ }
258
+ const { start: startStr, end: endStr } = c.req.valid("query");
259
+ const start = startStr ? parseInt(startStr) : 0;
260
+ const end = endStr ? parseInt(endStr) : 19;
261
+ const dlqQueue = createQueue(`${queueName}-dlq`);
262
+ const [jobs, total] = await Promise.all([
263
+ dlqQueue.getWaiting(start, end),
264
+ dlqQueue.getWaitingCount(),
265
+ ]);
266
+ const result = await Promise.all(jobs.map(jobToResponse));
267
+ return c.json({ jobs: result, total }, 200);
268
+ });
269
+ return router;
270
+ };
@@ -0,0 +1,5 @@
1
+ import type { AuthRateLimitConfig } from "../app";
2
+ export interface MfaRouterOptions {
3
+ rateLimit?: AuthRateLimitConfig;
4
+ }
5
+ export declare const createMfaRouter: ({ rateLimit }?: MfaRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;