@lastshotlabs/bunshot 0.0.10 → 0.0.16

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 (100) hide show
  1. package/README.md +2510 -1580
  2. package/dist/adapters/memoryAuth.d.ts +4 -0
  3. package/dist/adapters/memoryAuth.js +131 -2
  4. package/dist/adapters/mongoAuth.js +56 -0
  5. package/dist/adapters/sqliteAuth.d.ts +6 -0
  6. package/dist/adapters/sqliteAuth.js +137 -2
  7. package/dist/app.d.ts +107 -2
  8. package/dist/app.js +83 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +15 -5
  12. package/dist/index.js +10 -3
  13. package/dist/lib/appConfig.d.ts +46 -0
  14. package/dist/lib/appConfig.js +20 -0
  15. package/dist/lib/authAdapter.d.ts +30 -0
  16. package/dist/lib/constants.d.ts +2 -0
  17. package/dist/lib/constants.js +2 -0
  18. package/dist/lib/context.d.ts +2 -0
  19. package/dist/lib/createDtoMapper.d.ts +33 -0
  20. package/dist/lib/createDtoMapper.js +69 -0
  21. package/dist/lib/createRoute.d.ts +61 -0
  22. package/dist/lib/createRoute.js +147 -0
  23. package/dist/lib/jwt.d.ts +1 -1
  24. package/dist/lib/jwt.js +2 -2
  25. package/dist/lib/mfaChallenge.d.ts +20 -0
  26. package/dist/lib/mfaChallenge.js +184 -0
  27. package/dist/lib/queue.d.ts +33 -0
  28. package/dist/lib/queue.js +98 -0
  29. package/dist/lib/roles.d.ts +4 -0
  30. package/dist/lib/roles.js +27 -0
  31. package/dist/lib/session.d.ts +12 -0
  32. package/dist/lib/session.js +163 -5
  33. package/dist/lib/tenant.d.ts +15 -0
  34. package/dist/lib/tenant.js +65 -0
  35. package/dist/lib/zodToMongoose.d.ts +38 -0
  36. package/dist/lib/zodToMongoose.js +84 -0
  37. package/dist/middleware/cacheResponse.js +4 -1
  38. package/dist/middleware/rateLimit.d.ts +2 -1
  39. package/dist/middleware/rateLimit.js +5 -2
  40. package/dist/middleware/requireRole.d.ts +14 -3
  41. package/dist/middleware/requireRole.js +46 -6
  42. package/dist/middleware/tenant.d.ts +5 -0
  43. package/dist/middleware/tenant.js +116 -0
  44. package/dist/models/AuthUser.d.ts +8 -0
  45. package/dist/models/AuthUser.js +8 -0
  46. package/dist/models/TenantRole.d.ts +15 -0
  47. package/dist/models/TenantRole.js +23 -0
  48. package/dist/routes/auth.d.ts +5 -3
  49. package/dist/routes/auth.js +253 -80
  50. package/dist/routes/jobs.d.ts +2 -0
  51. package/dist/routes/jobs.js +270 -0
  52. package/dist/routes/mfa.d.ts +1 -0
  53. package/dist/routes/mfa.js +409 -0
  54. package/dist/routes/oauth.js +107 -16
  55. package/dist/server.js +9 -0
  56. package/dist/services/auth.d.ts +21 -2
  57. package/dist/services/auth.js +97 -17
  58. package/dist/services/mfa.d.ts +37 -0
  59. package/dist/services/mfa.js +276 -0
  60. package/docs/sections/adding-middleware/full.md +35 -0
  61. package/docs/sections/adding-models/full.md +125 -0
  62. package/docs/sections/adding-models/overview.md +13 -0
  63. package/docs/sections/adding-routes/full.md +182 -0
  64. package/docs/sections/adding-routes/overview.md +23 -0
  65. package/docs/sections/auth-flow/full.md +456 -0
  66. package/docs/sections/auth-flow/overview.md +10 -0
  67. package/docs/sections/cli/full.md +30 -0
  68. package/docs/sections/configuration/full.md +135 -0
  69. package/docs/sections/configuration/overview.md +17 -0
  70. package/docs/sections/configuration-example/full.md +99 -0
  71. package/docs/sections/configuration-example/overview.md +30 -0
  72. package/docs/sections/documentation/full.md +171 -0
  73. package/docs/sections/environment-variables/full.md +55 -0
  74. package/docs/sections/exports/full.md +83 -0
  75. package/docs/sections/extending-context/full.md +59 -0
  76. package/docs/sections/header.md +3 -0
  77. package/docs/sections/installation/full.md +6 -0
  78. package/docs/sections/jobs/full.md +140 -0
  79. package/docs/sections/jobs/overview.md +15 -0
  80. package/docs/sections/mongodb-connections/full.md +45 -0
  81. package/docs/sections/mongodb-connections/overview.md +7 -0
  82. package/docs/sections/multi-tenancy/full.md +62 -0
  83. package/docs/sections/multi-tenancy/overview.md +15 -0
  84. package/docs/sections/oauth/full.md +119 -0
  85. package/docs/sections/oauth/overview.md +16 -0
  86. package/docs/sections/package-development/full.md +7 -0
  87. package/docs/sections/peer-dependencies/full.md +43 -0
  88. package/docs/sections/quick-start/full.md +43 -0
  89. package/docs/sections/response-caching/full.md +115 -0
  90. package/docs/sections/response-caching/overview.md +13 -0
  91. package/docs/sections/roles/full.md +136 -0
  92. package/docs/sections/roles/overview.md +12 -0
  93. package/docs/sections/running-without-redis/full.md +16 -0
  94. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  95. package/docs/sections/stack/full.md +10 -0
  96. package/docs/sections/websocket/full.md +100 -0
  97. package/docs/sections/websocket/overview.md +5 -0
  98. package/docs/sections/websocket-rooms/full.md +97 -0
  99. package/docs/sections/websocket-rooms/overview.md +5 -0
  100. package/package.json +19 -10
@@ -1,29 +1,40 @@
1
- import { createRoute } from "@hono/zod-openapi";
1
+ 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
5
  import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
6
- import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "../lib/constants";
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 } from "../lib/appConfig";
14
+ import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
14
15
  const isProd = process.env.NODE_ENV === "production";
15
- const TokenResponse = z.object({ token: z.string(), emailVerified: z.boolean().optional() });
16
- const ErrorResponse = z.object({ error: z.string() });
16
+ const TokenResponse = z.object({
17
+ token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
18
+ userId: z.string().describe("Unique user ID."),
19
+ email: z.string().optional().describe("User's email address (present when primaryField is 'email')."),
20
+ emailVerified: z.boolean().optional().describe("Whether the email address has been verified (present when emailVerification is configured)."),
21
+ googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked to this user."),
22
+ refreshToken: z.string().optional().describe("Refresh token (present when refreshTokens is configured). Also set as an HttpOnly cookie."),
23
+ mfaRequired: z.boolean().optional().describe("When true, complete MFA via POST /auth/mfa/verify before accessing the API."),
24
+ mfaToken: z.string().optional().describe("MFA challenge token. Pass to POST /auth/mfa/verify with a TOTP or recovery code."),
25
+ mfaMethods: z.array(z.string()).optional().describe("Available MFA methods when mfaRequired is true (e.g., 'totp', 'emailOtp')."),
26
+ }).openapi("TokenResponse");
27
+ const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
17
28
  const tags = ["Auth"];
18
- const cookieOptions = {
29
+ const cookieOptions = (maxAge) => ({
19
30
  httpOnly: true,
20
31
  secure: isProd,
21
32
  sameSite: "Lax",
22
33
  path: "/",
23
- maxAge: 60 * 60 * 24 * 7, // 7 days
24
- };
34
+ maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
35
+ });
25
36
  const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
26
- export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit }) => {
37
+ export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
27
38
  const router = createRouter();
28
39
  const RegisterSchema = makeRegisterSchema(primaryField);
29
40
  const LoginSchema = makeLoginSchema(primaryField);
@@ -39,13 +50,15 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
39
50
  router.openapi(createRoute({
40
51
  method: "post",
41
52
  path: "/auth/register",
53
+ summary: "Register a new account",
54
+ description: "Creates a new user account and returns a JWT session token. The token is also set as an HttpOnly session cookie. Rate-limited by IP.",
42
55
  tags,
43
- request: { body: { content: { "application/json": { schema: RegisterSchema } } } },
56
+ request: { body: { content: { "application/json": { schema: RegisterSchema } }, description: "Registration credentials." } },
44
57
  responses: {
45
- 201: { content: { "application/json": { schema: TokenResponse } }, description: "Registered" },
46
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
58
+ 201: { content: { "application/json": { schema: TokenResponse } }, description: "Account created. Returns a session token." },
59
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. missing field, password too short)." },
47
60
  409: { content: { "application/json": { schema: ErrorResponse } }, description: alreadyRegisteredMsg },
48
- 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
61
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
49
62
  },
50
63
  }), async (c) => {
51
64
  const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
@@ -58,20 +71,25 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
58
71
  ipAddress: ip !== "unknown" ? ip : undefined,
59
72
  userAgent: c.req.header("user-agent") ?? undefined,
60
73
  };
61
- const token = await AuthService.register(identifier, body.password, metadata);
62
- setCookie(c, COOKIE_TOKEN, token, cookieOptions);
63
- return c.json({ token }, 201);
74
+ const result = await AuthService.register(identifier, body.password, metadata);
75
+ setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
76
+ if (result.refreshToken) {
77
+ setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
78
+ }
79
+ return c.json(result, 201);
64
80
  });
65
81
  router.openapi(createRoute({
66
82
  method: "post",
67
83
  path: "/auth/login",
84
+ summary: "Log in",
85
+ description: "Authenticates with credentials and returns a JWT session token. The token is also set as an HttpOnly session cookie. Failed attempts are rate-limited per identifier.",
68
86
  tags,
69
- request: { body: { content: { "application/json": { schema: LoginSchema } } } },
87
+ request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials." } },
70
88
  responses: {
71
- 200: { content: { "application/json": { schema: TokenResponse } }, description: "Logged in" },
72
- 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials" },
73
- 403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified" },
74
- 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
89
+ 200: { content: { "application/json": { schema: TokenResponse } }, description: "Authenticated. Returns a session token." },
90
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
91
+ 403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified. Verification is required before login." },
92
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many failed login attempts for this identifier. Try again later." },
75
93
  },
76
94
  }), async (c) => {
77
95
  const body = c.req.valid("json");
@@ -87,7 +105,12 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
87
105
  try {
88
106
  const result = await AuthService.login(identifier, body.password, metadata);
89
107
  await bustAuthLimit(limitKey); // success — clear failure count
90
- setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
108
+ if (!result.mfaRequired) {
109
+ setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
110
+ if (result.refreshToken) {
111
+ setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
112
+ }
113
+ }
91
114
  return c.json(result, 200);
92
115
  }
93
116
  catch (err) {
@@ -96,45 +119,118 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
96
119
  }
97
120
  });
98
121
  router.use("/auth/me", userAuth);
99
- router.openapi(createRoute({
122
+ router.openapi(withSecurity(createRoute({
100
123
  method: "get",
101
124
  path: "/auth/me",
125
+ summary: "Get current user",
126
+ description: "Returns the authenticated user's profile. Requires a valid session via cookie or x-user-token header.",
102
127
  tags,
103
128
  responses: {
104
129
  200: {
105
130
  content: {
106
131
  "application/json": {
107
132
  schema: z.object({
108
- userId: z.string(),
109
- email: z.string().optional(),
110
- emailVerified: z.boolean().optional(),
111
- googleLinked: z.boolean().optional(),
133
+ userId: z.string().describe("Unique user ID."),
134
+ email: z.string().optional().describe("User's email address."),
135
+ emailVerified: z.boolean().optional().describe("Whether the email address has been verified."),
136
+ googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked."),
112
137
  }),
113
138
  },
114
139
  },
115
- description: "Current user",
140
+ description: "Authenticated user's profile.",
116
141
  },
117
- 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
142
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
118
143
  },
119
- }), async (c) => {
144
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
120
145
  const authUserId = c.get("authUserId");
121
146
  const adapter = getAuthAdapter();
122
147
  const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
123
148
  const googleLinked = user?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
124
149
  return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
125
150
  });
151
+ // ---------------------------------------------------------------------------
152
+ // Account deletion
153
+ // ---------------------------------------------------------------------------
154
+ const deleteAccountOpts = { windowMs: rateLimit?.deleteAccount?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.deleteAccount?.max ?? 3 };
155
+ router.openapi(withSecurity(createRoute({
156
+ method: "delete",
157
+ path: "/auth/me",
158
+ summary: "Delete account",
159
+ 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.",
160
+ tags,
161
+ request: {
162
+ body: {
163
+ content: {
164
+ "application/json": {
165
+ schema: z.object({
166
+ password: z.string().optional().describe("Current password. Required for credential accounts, optional for OAuth-only accounts."),
167
+ }),
168
+ },
169
+ },
170
+ description: "Password confirmation.",
171
+ },
172
+ },
173
+ responses: {
174
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deleted." },
175
+ 202: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deletion has been scheduled." },
176
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Password is required for credential accounts." },
177
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid password or no valid session." },
178
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many deletion attempts. Try again later." },
179
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support deleteUser." },
180
+ },
181
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
182
+ const authUserId = c.get("authUserId");
183
+ if (await trackAttempt(`deleteaccount:${authUserId}`, deleteAccountOpts)) {
184
+ return c.json({ error: "Too many deletion attempts. Try again later." }, 429);
185
+ }
186
+ const adapter = getAuthAdapter();
187
+ if (!adapter.deleteUser) {
188
+ return c.json({ error: "Auth adapter does not support deleteUser" }, 501);
189
+ }
190
+ const { password } = c.req.valid("json");
191
+ // Verify password for credential accounts
192
+ if (password) {
193
+ const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
194
+ const email = user?.email;
195
+ if (email) {
196
+ const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
197
+ const found = await findFn(email);
198
+ if (found && !(await Bun.password.verify(password, found.passwordHash))) {
199
+ return c.json({ error: "Invalid password" }, 401);
200
+ }
201
+ }
202
+ }
203
+ else if (adapter.hasPassword && await adapter.hasPassword(authUserId)) {
204
+ return c.json({ error: "Password is required to delete a credential account" }, 400);
205
+ }
206
+ // Call onBeforeDelete hook
207
+ if (accountDeletion?.onBeforeDelete) {
208
+ await accountDeletion.onBeforeDelete(authUserId);
209
+ }
210
+ // Synchronous deletion (default)
211
+ await deleteUserSessions(authUserId);
212
+ await adapter.deleteUser(authUserId);
213
+ if (accountDeletion?.onAfterDelete) {
214
+ await accountDeletion.onAfterDelete(authUserId);
215
+ }
216
+ deleteCookie(c, COOKIE_TOKEN, { path: "/" });
217
+ return c.json({ message: "Account deleted" }, 200);
218
+ });
126
219
  router.use("/auth/set-password", userAuth);
127
- router.openapi(createRoute({
220
+ router.openapi(withSecurity(createRoute({
128
221
  method: "post",
129
222
  path: "/auth/set-password",
223
+ summary: "Set or update password",
224
+ description: "Sets or updates the password for the authenticated user. Useful for OAuth-only users who want to add a password. Requires a valid session.",
130
225
  tags,
131
- request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8) }) } } } },
226
+ request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8).describe("New password. Minimum 8 characters.") }) } }, description: "New password." } },
132
227
  responses: {
133
- 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password set" },
134
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
135
- 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
228
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password updated successfully." },
229
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. password too short)." },
230
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
231
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
136
232
  },
137
- }), async (c) => {
233
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
138
234
  const adapter = getAuthAdapter();
139
235
  if (!adapter.setPassword) {
140
236
  return c.json({ error: "Auth adapter does not support setPassword" }, 501);
@@ -148,14 +244,17 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
148
244
  router.openapi(createRoute({
149
245
  method: "post",
150
246
  path: "/auth/logout",
247
+ summary: "Log out",
248
+ description: "Invalidates the current session and clears the session cookie. Safe to call even without an active session.",
151
249
  tags,
152
250
  responses: {
153
- 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out" },
251
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out. Session is invalidated and cookie is cleared." },
154
252
  },
155
253
  }), async (c) => {
156
254
  const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
157
255
  await AuthService.logout(token);
158
256
  deleteCookie(c, COOKIE_TOKEN, { path: "/" });
257
+ deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
159
258
  return c.json({ message: "Logged out" }, 200);
160
259
  });
161
260
  // Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
@@ -163,12 +262,14 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
163
262
  router.openapi(createRoute({
164
263
  method: "post",
165
264
  path: "/auth/verify-email",
265
+ summary: "Verify email address",
266
+ description: "Consumes a single-use email verification token and marks the account as verified. The token is delivered by the `emailVerification.onSend` callback configured in CreateAppConfig. Rate-limited by IP.",
166
267
  tags,
167
- request: { body: { content: { "application/json": { schema: z.object({ token: z.string() }) } } } },
268
+ request: { body: { content: { "application/json": { schema: z.object({ token: z.string().describe("Single-use verification token received via email.") }) } }, description: "Verification token." } },
168
269
  responses: {
169
- 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified" },
170
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired token" },
171
- 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
270
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified successfully." },
271
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired verification token." },
272
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
172
273
  },
173
274
  }), async (c) => {
174
275
  const ip = c.req.header("x-forwarded-for") ?? "unknown";
@@ -185,34 +286,43 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
185
286
  await deleteVerificationToken(token);
186
287
  return c.json({ message: "Email verified" }, 200);
187
288
  });
188
- router.use("/auth/resend-verification", userAuth);
189
289
  router.openapi(createRoute({
190
290
  method: "post",
191
291
  path: "/auth/resend-verification",
292
+ summary: "Resend verification email",
293
+ description: "Authenticates with credentials and sends a new verification email. Returns 400 if already verified. Rate-limited per identifier. Does not require a session.",
192
294
  tags,
295
+ request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials to identify the account." } },
193
296
  responses: {
194
- 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent" },
195
- 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Already verified" },
196
- 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
197
- 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
297
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
298
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Email is already verified, or no email address on file." },
299
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
300
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this identifier. Try again later." },
301
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
198
302
  },
199
303
  }), async (c) => {
200
304
  const adapter = getAuthAdapter();
201
305
  if (!adapter.getEmailVerified || !adapter.getUser) {
202
306
  return c.json({ error: "Auth adapter does not support email verification" }, 501);
203
307
  }
204
- const authUserId = c.get("authUserId");
205
- if (await trackAttempt(`resend:${authUserId}`, resendOpts)) {
308
+ const body = c.req.valid("json");
309
+ const identifier = body[primaryField];
310
+ if (await trackAttempt(`resend:${identifier}`, resendOpts)) {
206
311
  return c.json({ error: "Too many resend attempts. Try again later." }, 429);
207
312
  }
208
- const alreadyVerified = await adapter.getEmailVerified(authUserId);
313
+ const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
314
+ const user = await findFn(identifier);
315
+ if (!user || !(await Bun.password.verify(body.password, user.passwordHash))) {
316
+ return c.json({ error: "Invalid credentials" }, 401);
317
+ }
318
+ const alreadyVerified = await adapter.getEmailVerified(user.id);
209
319
  if (alreadyVerified)
210
320
  return c.json({ error: "Email already verified" }, 400);
211
- const user = await adapter.getUser(authUserId);
212
- if (!user?.email)
321
+ const fullUser = await adapter.getUser(user.id);
322
+ if (!fullUser?.email)
213
323
  return c.json({ error: "No email address on file" }, 400);
214
- const verificationToken = await createVerificationToken(authUserId, user.email);
215
- await emailVerification.onSend(user.email, verificationToken);
324
+ const verificationToken = await createVerificationToken(user.id, fullUser.email);
325
+ await emailVerification.onSend(fullUser.email, verificationToken);
216
326
  return c.json({ message: "Verification email sent" }, 200);
217
327
  });
218
328
  }
@@ -221,12 +331,14 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
221
331
  router.openapi(createRoute({
222
332
  method: "post",
223
333
  path: "/auth/forgot-password",
334
+ summary: "Request password reset",
335
+ description: "Sends a password reset email if the address is registered. Always returns 200 regardless of whether the address exists, to prevent email enumeration. Rate-limited by both IP and email address.",
224
336
  tags,
225
- request: { body: { content: { "application/json": { schema: z.object({ email: z.string().email() }) } } } },
337
+ request: { body: { content: { "application/json": { schema: z.object({ email: z.string().email().describe("Email address to send the reset link to.") }) } }, description: "Email address for the account to reset." } },
226
338
  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" },
339
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Request received. A reset email will be sent if the address is registered." },
340
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error (e.g. not a valid email address)." },
341
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
230
342
  },
231
343
  }), async (c) => {
232
344
  const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
@@ -258,13 +370,27 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
258
370
  router.openapi(createRoute({
259
371
  method: "post",
260
372
  path: "/auth/reset-password",
373
+ summary: "Reset password",
374
+ description: "Consumes a single-use reset token and sets a new password. All active sessions are revoked after a successful reset to invalidate any stolen JWTs. Rate-limited by IP.",
261
375
  tags,
262
- request: { body: { content: { "application/json": { schema: z.object({ token: z.string(), password: z.string().min(8) }) } } } },
376
+ request: {
377
+ body: {
378
+ content: {
379
+ "application/json": {
380
+ schema: z.object({
381
+ token: z.string().describe("Single-use reset token received via email."),
382
+ password: z.string().min(8).describe("New password. Minimum 8 characters."),
383
+ }),
384
+ },
385
+ },
386
+ description: "Reset token and new password.",
387
+ },
388
+ },
263
389
  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" },
390
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password reset. All sessions have been revoked." },
391
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error, or the reset token is invalid or expired." },
392
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many reset attempts from this IP. Try again later." },
393
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
268
394
  },
269
395
  }), async (c) => {
270
396
  const ip = clientIp(c.req.header("x-forwarded-for"), c.req.header("x-real-ip")) ?? "unknown";
@@ -289,46 +415,93 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
289
415
  });
290
416
  }
291
417
  // ---------------------------------------------------------------------------
418
+ // Refresh token route — only mounted when refreshTokens is configured
419
+ // ---------------------------------------------------------------------------
420
+ if (refreshTokens) {
421
+ const RefreshResponse = z.object({
422
+ token: z.string().describe("New short-lived JWT access token."),
423
+ refreshToken: z.string().describe("New refresh token (rotation). The previous token is valid for a short grace window."),
424
+ userId: z.string().describe("Unique user ID."),
425
+ }).openapi("RefreshResponse");
426
+ router.openapi(createRoute({
427
+ method: "post",
428
+ path: "/auth/refresh",
429
+ summary: "Refresh access token",
430
+ 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).",
431
+ tags,
432
+ request: {
433
+ body: {
434
+ content: {
435
+ "application/json": {
436
+ schema: z.object({
437
+ refreshToken: z.string().optional().describe("Refresh token. Can also be sent via the refresh_token cookie or x-refresh-token header."),
438
+ }),
439
+ },
440
+ },
441
+ description: "Refresh token (optional if sent via cookie or header).",
442
+ },
443
+ },
444
+ responses: {
445
+ 200: { content: { "application/json": { schema: RefreshResponse } }, description: "New access and refresh tokens." },
446
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired refresh token, or session invalidated due to token theft detection." },
447
+ },
448
+ }), async (c) => {
449
+ const body = c.req.valid("json");
450
+ const rt = body.refreshToken ?? getCookie(c, COOKIE_REFRESH_TOKEN) ?? c.req.header(HEADER_REFRESH_TOKEN) ?? null;
451
+ if (!rt) {
452
+ return c.json({ error: "Refresh token is required" }, 401);
453
+ }
454
+ const result = await AuthService.refresh(rt);
455
+ setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(getAccessTokenExpiry()));
456
+ setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
457
+ return c.json(result, 200);
458
+ });
459
+ }
460
+ // ---------------------------------------------------------------------------
292
461
  // Session management
293
462
  // ---------------------------------------------------------------------------
294
463
  const SessionInfoSchema = z.object({
295
- sessionId: z.string(),
296
- createdAt: z.number(),
297
- lastActiveAt: z.number(),
298
- expiresAt: z.number(),
299
- ipAddress: z.string().optional(),
300
- userAgent: z.string().optional(),
301
- isActive: z.boolean(),
302
- });
464
+ sessionId: z.string().describe("Unique session identifier (UUID)."),
465
+ createdAt: z.number().describe("Unix timestamp (ms) when the session was created."),
466
+ lastActiveAt: z.number().describe("Unix timestamp (ms) of the most recent authenticated request (updated when trackLastActive is enabled)."),
467
+ expiresAt: z.number().describe("Unix timestamp (ms) when the session expires."),
468
+ ipAddress: z.string().optional().describe("IP address of the client at session creation."),
469
+ userAgent: z.string().optional().describe("User-agent string of the client at session creation."),
470
+ isActive: z.boolean().describe("Whether the session is currently valid and unexpired."),
471
+ }).openapi("SessionInfo");
303
472
  router.use("/auth/sessions", userAuth);
304
473
  router.use("/auth/sessions/*", userAuth);
305
- router.openapi(createRoute({
474
+ router.openapi(withSecurity(createRoute({
306
475
  method: "get",
307
476
  path: "/auth/sessions",
477
+ summary: "List sessions",
478
+ description: "Returns all sessions for the authenticated user. Includes inactive sessions when `sessionPolicy.includeInactiveSessions` is enabled. Requires a valid session.",
308
479
  tags,
309
480
  responses: {
310
481
  200: {
311
482
  content: { "application/json": { schema: z.object({ sessions: z.array(SessionInfoSchema) }) } },
312
- description: "List of sessions for the current user",
483
+ description: "Sessions belonging to the authenticated user.",
313
484
  },
314
- 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
485
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
315
486
  },
316
- }), async (c) => {
487
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
317
488
  const userId = c.get("authUserId");
318
489
  const sessions = await getUserSessions(userId);
319
490
  return c.json({ sessions }, 200);
320
491
  });
321
- router.openapi(createRoute({
492
+ router.openapi(withSecurity(createRoute({
322
493
  method: "delete",
323
494
  path: "/auth/sessions/{sessionId}",
495
+ summary: "Revoke a session",
496
+ description: "Revokes a specific session by ID. Users can only revoke their own sessions. Useful for 'sign out of other devices' flows. Requires a valid session.",
324
497
  tags,
325
- request: { params: z.object({ sessionId: z.string() }) },
498
+ request: { params: z.object({ sessionId: z.string().describe("UUID of the session to revoke.") }) },
326
499
  responses: {
327
- 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Session revoked" },
328
- 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
329
- 404: { content: { "application/json": { schema: ErrorResponse } }, description: "Session not found" },
500
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Session revoked successfully." },
501
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
502
+ 404: { content: { "application/json": { schema: ErrorResponse } }, description: "Session not found or does not belong to the authenticated user." },
330
503
  },
331
- }), async (c) => {
504
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
332
505
  const userId = c.get("authUserId");
333
506
  const { sessionId } = c.req.valid("param");
334
507
  const sessions = await getUserSessions(userId);
@@ -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, {}, "/">;