@lastshotlabs/bunshot 0.0.13 → 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 (98) hide show
  1. package/README.md +2510 -1747
  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 +77 -2
  8. package/dist/app.js +29 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +14 -5
  12. package/dist/index.js +9 -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/jwt.d.ts +1 -1
  22. package/dist/lib/jwt.js +2 -2
  23. package/dist/lib/mfaChallenge.d.ts +20 -0
  24. package/dist/lib/mfaChallenge.js +184 -0
  25. package/dist/lib/queue.d.ts +33 -0
  26. package/dist/lib/queue.js +98 -0
  27. package/dist/lib/roles.d.ts +4 -0
  28. package/dist/lib/roles.js +27 -0
  29. package/dist/lib/session.d.ts +12 -0
  30. package/dist/lib/session.js +163 -5
  31. package/dist/lib/tenant.d.ts +15 -0
  32. package/dist/lib/tenant.js +65 -0
  33. package/dist/lib/zodToMongoose.d.ts +38 -0
  34. package/dist/lib/zodToMongoose.js +84 -0
  35. package/dist/middleware/cacheResponse.js +4 -1
  36. package/dist/middleware/rateLimit.d.ts +2 -1
  37. package/dist/middleware/rateLimit.js +5 -2
  38. package/dist/middleware/requireRole.d.ts +14 -3
  39. package/dist/middleware/requireRole.js +46 -6
  40. package/dist/middleware/tenant.d.ts +5 -0
  41. package/dist/middleware/tenant.js +116 -0
  42. package/dist/models/AuthUser.d.ts +8 -0
  43. package/dist/models/AuthUser.js +8 -0
  44. package/dist/models/TenantRole.d.ts +15 -0
  45. package/dist/models/TenantRole.js +23 -0
  46. package/dist/routes/auth.d.ts +5 -3
  47. package/dist/routes/auth.js +153 -22
  48. package/dist/routes/jobs.d.ts +2 -0
  49. package/dist/routes/jobs.js +270 -0
  50. package/dist/routes/mfa.d.ts +1 -0
  51. package/dist/routes/mfa.js +409 -0
  52. package/dist/routes/oauth.js +107 -16
  53. package/dist/server.js +9 -0
  54. package/dist/services/auth.d.ts +17 -5
  55. package/dist/services/auth.js +95 -17
  56. package/dist/services/mfa.d.ts +37 -0
  57. package/dist/services/mfa.js +276 -0
  58. package/docs/sections/adding-middleware/full.md +35 -0
  59. package/docs/sections/adding-models/full.md +125 -0
  60. package/docs/sections/adding-models/overview.md +13 -0
  61. package/docs/sections/adding-routes/full.md +182 -0
  62. package/docs/sections/adding-routes/overview.md +23 -0
  63. package/docs/sections/auth-flow/full.md +456 -0
  64. package/docs/sections/auth-flow/overview.md +10 -0
  65. package/docs/sections/cli/full.md +30 -0
  66. package/docs/sections/configuration/full.md +135 -0
  67. package/docs/sections/configuration/overview.md +17 -0
  68. package/docs/sections/configuration-example/full.md +99 -0
  69. package/docs/sections/configuration-example/overview.md +30 -0
  70. package/docs/sections/documentation/full.md +171 -0
  71. package/docs/sections/environment-variables/full.md +55 -0
  72. package/docs/sections/exports/full.md +83 -0
  73. package/docs/sections/extending-context/full.md +59 -0
  74. package/docs/sections/header.md +3 -0
  75. package/docs/sections/installation/full.md +6 -0
  76. package/docs/sections/jobs/full.md +140 -0
  77. package/docs/sections/jobs/overview.md +15 -0
  78. package/docs/sections/mongodb-connections/full.md +45 -0
  79. package/docs/sections/mongodb-connections/overview.md +7 -0
  80. package/docs/sections/multi-tenancy/full.md +62 -0
  81. package/docs/sections/multi-tenancy/overview.md +15 -0
  82. package/docs/sections/oauth/full.md +119 -0
  83. package/docs/sections/oauth/overview.md +16 -0
  84. package/docs/sections/package-development/full.md +7 -0
  85. package/docs/sections/peer-dependencies/full.md +43 -0
  86. package/docs/sections/quick-start/full.md +43 -0
  87. package/docs/sections/response-caching/full.md +115 -0
  88. package/docs/sections/response-caching/overview.md +13 -0
  89. package/docs/sections/roles/full.md +136 -0
  90. package/docs/sections/roles/overview.md +12 -0
  91. package/docs/sections/running-without-redis/full.md +16 -0
  92. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  93. package/docs/sections/stack/full.md +10 -0
  94. package/docs/sections/websocket/full.md +100 -0
  95. package/docs/sections/websocket/overview.md +5 -0
  96. package/docs/sections/websocket-rooms/full.md +97 -0
  97. package/docs/sections/websocket-rooms/overview.md +5 -0
  98. package/package.json +19 -10
@@ -2,9 +2,11 @@ import { getAuthAdapter } from "../lib/authAdapter";
2
2
  /**
3
3
  * Middleware factory that enforces role-based access.
4
4
  * Requires `identify` to have run first (authUserId must be set).
5
- * Roles are fetched lazily on the first role-checked route and cached on the context.
6
5
  *
7
- * The adapter must implement `getRoles` for this to work.
6
+ * When tenant context exists (`tenantId` set on context), checks tenant-scoped roles.
7
+ * Falls back to app-wide roles when no tenant context is present.
8
+ *
9
+ * The adapter must implement `getRoles` (and `getTenantRoles` for tenant-scoped checks).
8
10
  *
9
11
  * @example
10
12
  * // Allow any authenticated user with the "admin" role
@@ -13,15 +15,25 @@ import { getAuthAdapter } from "../lib/authAdapter";
13
15
  * // Allow users with either "admin" or "moderator"
14
16
  * app.get("/mod", userAuth, requireRole("admin", "moderator"), handler)
15
17
  */
16
- export const requireRole = (...roles) => async (c, next) => {
18
+ export const requireRole = Object.assign((...roles) => async (c, next) => {
17
19
  const userId = c.get("authUserId");
18
20
  if (!userId) {
19
21
  return c.json({ error: "Unauthorized" }, 401);
20
22
  }
21
- // Lazy-fetch roles and cache on context so multiple requireRole calls in a chain only hit the adapter once
23
+ const adapter = getAuthAdapter();
24
+ const tenantId = c.get("tenantId");
25
+ // When tenant context exists and adapter supports tenant roles, check tenant-scoped roles
26
+ if (tenantId && adapter.getTenantRoles) {
27
+ const tenantRoles = await adapter.getTenantRoles(userId, tenantId);
28
+ const hasRole = roles.some((role) => tenantRoles.includes(role));
29
+ if (!hasRole) {
30
+ return c.json({ error: "Forbidden" }, 403);
31
+ }
32
+ return next();
33
+ }
34
+ // Fall back to app-wide roles
22
35
  let userRoles = c.get("roles");
23
36
  if (userRoles === null) {
24
- const adapter = getAuthAdapter();
25
37
  if (!adapter.getRoles) {
26
38
  throw new Error("requireRole used but auth adapter does not implement getRoles");
27
39
  }
@@ -33,4 +45,32 @@ export const requireRole = (...roles) => async (c, next) => {
33
45
  return c.json({ error: "Forbidden" }, 403);
34
46
  }
35
47
  await next();
36
- };
48
+ }, {
49
+ /**
50
+ * Always checks app-wide roles regardless of tenant context.
51
+ * Use for super-admin gates that should ignore tenant scoping.
52
+ *
53
+ * @example
54
+ * app.get("/super-admin", userAuth, requireRole.global("superadmin"), handler)
55
+ */
56
+ global: (...roles) => async (c, next) => {
57
+ const userId = c.get("authUserId");
58
+ if (!userId) {
59
+ return c.json({ error: "Unauthorized" }, 401);
60
+ }
61
+ let userRoles = c.get("roles");
62
+ if (userRoles === null) {
63
+ const adapter = getAuthAdapter();
64
+ if (!adapter.getRoles) {
65
+ throw new Error("requireRole.global used but auth adapter does not implement getRoles");
66
+ }
67
+ userRoles = await adapter.getRoles(userId);
68
+ c.set("roles", userRoles);
69
+ }
70
+ const hasRole = roles.some((role) => userRoles.includes(role));
71
+ if (!hasRole) {
72
+ return c.json({ error: "Forbidden" }, 403);
73
+ }
74
+ await next();
75
+ },
76
+ });
@@ -0,0 +1,5 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { AppEnv } from "../lib/context";
3
+ import type { TenancyConfig } from "../app";
4
+ export declare const invalidateTenantCache: (tenantId: string) => void;
5
+ export declare const createTenantMiddleware: (config: TenancyConfig) => MiddlewareHandler<AppEnv>;
@@ -0,0 +1,116 @@
1
+ class LruCache {
2
+ _map = new Map();
3
+ _maxSize;
4
+ _ttlMs;
5
+ constructor(maxSize, ttlMs) {
6
+ this._maxSize = maxSize;
7
+ this._ttlMs = ttlMs;
8
+ }
9
+ get(key) {
10
+ const entry = this._map.get(key);
11
+ if (!entry)
12
+ return undefined; // cache miss
13
+ if (entry.expiresAt <= Date.now()) {
14
+ this._map.delete(key);
15
+ return undefined; // expired
16
+ }
17
+ // Move to end (most recently used)
18
+ this._map.delete(key);
19
+ this._map.set(key, entry);
20
+ return entry.value;
21
+ }
22
+ set(key, value) {
23
+ // Remove first if exists (for re-insertion at end)
24
+ this._map.delete(key);
25
+ // Evict oldest if at capacity
26
+ if (this._map.size >= this._maxSize) {
27
+ const oldest = this._map.keys().next().value;
28
+ if (oldest !== undefined)
29
+ this._map.delete(oldest);
30
+ }
31
+ this._map.set(key, { value, expiresAt: Date.now() + this._ttlMs });
32
+ }
33
+ delete(key) {
34
+ this._map.delete(key);
35
+ }
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Exported cache invalidation (used by tenant provisioning helpers)
39
+ // ---------------------------------------------------------------------------
40
+ let _cache = null;
41
+ export const invalidateTenantCache = (tenantId) => {
42
+ _cache?.delete(tenantId);
43
+ };
44
+ // ---------------------------------------------------------------------------
45
+ // Tenant resolution middleware
46
+ // ---------------------------------------------------------------------------
47
+ const DEFAULT_EXEMPT = ["/health", "/docs", "/openapi.json", "/auth/"];
48
+ function extractTenantId(c, config) {
49
+ if (config.resolution === "header") {
50
+ const headerName = config.headerName ?? "x-tenant-id";
51
+ return c.req.header(headerName) ?? null;
52
+ }
53
+ if (config.resolution === "subdomain") {
54
+ const host = c.req.header("host") ?? "";
55
+ // Extract first subdomain: "acme.myapp.com" → "acme"
56
+ const parts = host.split(".");
57
+ if (parts.length < 3)
58
+ return null; // no subdomain
59
+ return parts[0] || null;
60
+ }
61
+ if (config.resolution === "path") {
62
+ const segmentIndex = config.pathSegment ?? 0;
63
+ // Path: "/acme/api/users" → segments after split: ["", "acme", "api", "users"]
64
+ const segments = c.req.path.split("/").filter(Boolean);
65
+ return segments[segmentIndex] ?? null;
66
+ }
67
+ return null;
68
+ }
69
+ export const createTenantMiddleware = (config) => {
70
+ const exemptPaths = [...DEFAULT_EXEMPT, ...(config.exemptPaths ?? [])];
71
+ const rejectionStatus = config.rejectionStatus ?? 403;
72
+ const cacheTtlMs = config.cacheTtlMs ?? 60_000;
73
+ const cacheMaxSize = config.cacheMaxSize ?? 500;
74
+ // Initialize LRU cache if caching is enabled and onResolve is provided
75
+ if (config.onResolve && cacheTtlMs > 0) {
76
+ _cache = new LruCache(cacheMaxSize, cacheTtlMs);
77
+ }
78
+ return async (c, next) => {
79
+ const path = c.req.path;
80
+ // Check exempt paths using startsWith
81
+ for (const exempt of exemptPaths) {
82
+ if (path === exempt || path.startsWith(exempt)) {
83
+ c.set("tenantId", null);
84
+ c.set("tenantConfig", null);
85
+ return next();
86
+ }
87
+ }
88
+ const tenantId = extractTenantId(c, config);
89
+ if (!tenantId) {
90
+ return c.json({ error: "Tenant ID required" }, 400);
91
+ }
92
+ // Validate via onResolve (with caching)
93
+ if (config.onResolve) {
94
+ let tenantConfig;
95
+ if (_cache) {
96
+ tenantConfig = _cache.get(tenantId);
97
+ }
98
+ // undefined = cache miss, null = onResolve returned null (rejected)
99
+ if (tenantConfig === undefined) {
100
+ tenantConfig = await config.onResolve(tenantId);
101
+ _cache?.set(tenantId, tenantConfig);
102
+ }
103
+ if (tenantConfig === null) {
104
+ return c.json({ error: "Access denied" }, rejectionStatus);
105
+ }
106
+ c.set("tenantId", tenantId);
107
+ c.set("tenantConfig", tenantConfig);
108
+ }
109
+ else {
110
+ // No onResolve — trust the tenant ID
111
+ c.set("tenantId", tenantId);
112
+ c.set("tenantConfig", null);
113
+ }
114
+ return next();
115
+ };
116
+ };
@@ -8,6 +8,14 @@ interface IAuthUser {
8
8
  roles: string[];
9
9
  /** Whether the user's email address has been verified. */
10
10
  emailVerified: boolean;
11
+ /** TOTP secret for MFA. Null when MFA is not set up. */
12
+ mfaSecret?: string | null;
13
+ /** Whether MFA is enabled (secret stored + confirmed via TOTP code). */
14
+ mfaEnabled?: boolean;
15
+ /** SHA-256 hashed recovery codes for MFA. */
16
+ recoveryCodes?: string[];
17
+ /** MFA methods enabled for this user (e.g., ["totp"], ["emailOtp"], ["totp", "emailOtp"]). */
18
+ mfaMethods?: string[];
11
19
  }
12
20
  type AuthUserDocument = IAuthUser & Document;
13
21
  export declare const AuthUser: Model<AuthUserDocument, {}, {}, {}, Document<unknown, {}, AuthUserDocument, {}, import("mongoose").DefaultSchemaOptions> & IAuthUser & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
@@ -14,6 +14,14 @@ function getAuthUser() {
14
14
  roles: [{ type: String }],
15
15
  /** Whether the user's email address has been verified. */
16
16
  emailVerified: { type: Boolean, default: false },
17
+ /** TOTP secret for MFA. */
18
+ mfaSecret: { type: String, default: null },
19
+ /** Whether MFA is enabled. */
20
+ mfaEnabled: { type: Boolean, default: false },
21
+ /** SHA-256 hashed recovery codes for MFA. */
22
+ recoveryCodes: [{ type: String }],
23
+ /** MFA methods enabled for this user. */
24
+ mfaMethods: [{ type: String }],
17
25
  }, { timestamps: true });
18
26
  schema.index({ providerIds: 1 });
19
27
  _AuthUser = authConnection.model("AuthUser", schema);
@@ -0,0 +1,15 @@
1
+ import type { Document, Model } from "mongoose";
2
+ interface ITenantRole {
3
+ userId: string;
4
+ tenantId: string;
5
+ roles: string[];
6
+ }
7
+ type TenantRoleDocument = ITenantRole & Document;
8
+ export declare const TenantRole: Model<TenantRoleDocument, {}, {}, {}, Document<unknown, {}, TenantRoleDocument, {}, import("mongoose").DefaultSchemaOptions> & ITenantRole & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
9
+ _id: import("mongoose").Types.ObjectId;
10
+ }> & {
11
+ __v: number;
12
+ } & {
13
+ id: string;
14
+ }, any, TenantRoleDocument>;
15
+ export {};
@@ -0,0 +1,23 @@
1
+ import { authConnection, mongoose } from "../lib/mongo";
2
+ let _TenantRole = null;
3
+ function getTenantRole() {
4
+ if (!_TenantRole) {
5
+ const { Schema } = mongoose;
6
+ const schema = new Schema({
7
+ userId: { type: String, required: true },
8
+ tenantId: { type: String, required: true },
9
+ roles: [{ type: String }],
10
+ }, { timestamps: true });
11
+ schema.index({ userId: 1, tenantId: 1 }, { unique: true });
12
+ schema.index({ tenantId: 1 });
13
+ _TenantRole = authConnection.model("TenantRole", schema);
14
+ }
15
+ return _TenantRole;
16
+ }
17
+ export const TenantRole = new Proxy({}, {
18
+ get(_, prop) {
19
+ const model = getTenantRole();
20
+ const val = model[prop];
21
+ return typeof val === "function" ? val.bind(model) : val;
22
+ },
23
+ });
@@ -1,9 +1,11 @@
1
- import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig } from "../lib/appConfig";
2
- import type { AuthRateLimitConfig } from "../app";
1
+ import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig } from "../lib/appConfig";
2
+ import type { AuthRateLimitConfig, AccountDeletionConfig } from "../app";
3
3
  export interface AuthRouterOptions {
4
4
  primaryField: PrimaryField;
5
5
  emailVerification?: EmailVerificationConfig;
6
6
  passwordReset?: PasswordResetConfig;
7
7
  rateLimit?: AuthRateLimitConfig;
8
+ accountDeletion?: AccountDeletionConfig;
9
+ refreshTokens?: RefreshTokenConfig;
8
10
  }
9
- export declare const createAuthRouter: ({ primaryField, emailVerification, passwordReset, rateLimit }: AuthRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
11
+ export declare const createAuthRouter: ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }: AuthRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
@@ -3,33 +3,38 @@ 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
16
  const TokenResponse = z.object({
16
- token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie."),
17
+ token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
17
18
  userId: z.string().describe("Unique user ID."),
18
19
  email: z.string().optional().describe("User's email address (present when primaryField is 'email')."),
19
20
  emailVerified: z.boolean().optional().describe("Whether the email address has been verified (present when emailVerification is configured)."),
20
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')."),
21
26
  }).openapi("TokenResponse");
22
27
  const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
23
28
  const tags = ["Auth"];
24
- const cookieOptions = {
29
+ const cookieOptions = (maxAge) => ({
25
30
  httpOnly: true,
26
31
  secure: isProd,
27
32
  sameSite: "Lax",
28
33
  path: "/",
29
- maxAge: 60 * 60 * 24 * 7, // 7 days
30
- };
34
+ maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
35
+ });
31
36
  const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
32
- export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit }) => {
37
+ export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
33
38
  const router = createRouter();
34
39
  const RegisterSchema = makeRegisterSchema(primaryField);
35
40
  const LoginSchema = makeLoginSchema(primaryField);
@@ -67,7 +72,10 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
67
72
  userAgent: c.req.header("user-agent") ?? undefined,
68
73
  };
69
74
  const result = await AuthService.register(identifier, body.password, metadata);
70
- setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
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
+ }
71
79
  return c.json(result, 201);
72
80
  });
73
81
  router.openapi(createRoute({
@@ -97,7 +105,12 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
97
105
  try {
98
106
  const result = await AuthService.login(identifier, body.password, metadata);
99
107
  await bustAuthLimit(limitKey); // success — clear failure count
100
- 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
+ }
101
114
  return c.json(result, 200);
102
115
  }
103
116
  catch (err) {
@@ -135,6 +148,74 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
135
148
  const googleLinked = user?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
136
149
  return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
137
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
+ });
138
219
  router.use("/auth/set-password", userAuth);
139
220
  router.openapi(withSecurity(createRoute({
140
221
  method: "post",
@@ -173,6 +254,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
173
254
  const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
174
255
  await AuthService.logout(token);
175
256
  deleteCookie(c, COOKIE_TOKEN, { path: "/" });
257
+ deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
176
258
  return c.json({ message: "Logged out" }, 200);
177
259
  });
178
260
  // Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
@@ -204,37 +286,43 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
204
286
  await deleteVerificationToken(token);
205
287
  return c.json({ message: "Email verified" }, 200);
206
288
  });
207
- router.use("/auth/resend-verification", userAuth);
208
- router.openapi(withSecurity(createRoute({
289
+ router.openapi(createRoute({
209
290
  method: "post",
210
291
  path: "/auth/resend-verification",
211
292
  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.",
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.",
213
294
  tags,
295
+ request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials to identify the account." } },
214
296
  responses: {
215
297
  200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
216
298
  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." },
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." },
219
301
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
220
302
  },
221
- }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
303
+ }), async (c) => {
222
304
  const adapter = getAuthAdapter();
223
305
  if (!adapter.getEmailVerified || !adapter.getUser) {
224
306
  return c.json({ error: "Auth adapter does not support email verification" }, 501);
225
307
  }
226
- const authUserId = c.get("authUserId");
227
- 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)) {
228
311
  return c.json({ error: "Too many resend attempts. Try again later." }, 429);
229
312
  }
230
- 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);
231
319
  if (alreadyVerified)
232
320
  return c.json({ error: "Email already verified" }, 400);
233
- const user = await adapter.getUser(authUserId);
234
- if (!user?.email)
321
+ const fullUser = await adapter.getUser(user.id);
322
+ if (!fullUser?.email)
235
323
  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);
324
+ const verificationToken = await createVerificationToken(user.id, fullUser.email);
325
+ await emailVerification.onSend(fullUser.email, verificationToken);
238
326
  return c.json({ message: "Verification email sent" }, 200);
239
327
  });
240
328
  }
@@ -327,6 +415,49 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
327
415
  });
328
416
  }
329
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
+ // ---------------------------------------------------------------------------
330
461
  // Session management
331
462
  // ---------------------------------------------------------------------------
332
463
  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, {}, "/">;