@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
@@ -1,9 +1,31 @@
1
1
  import { getAuthAdapter } from "../lib/authAdapter";
2
2
  import { HttpError } from "../lib/HttpError";
3
3
  import { signToken, verifyToken } from "../lib/jwt";
4
- import { createSession, deleteSession, getActiveSessionCount, evictOldestSession } from "../lib/session";
5
- import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions } from "../lib/appConfig";
4
+ import { createSession, deleteSession, getActiveSessionCount, evictOldestSession, deleteUserSessions, setRefreshToken, getSessionByRefreshToken, rotateRefreshToken } from "../lib/session";
5
+ import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getMfaConfig, getMfaEmailOtpConfig } from "../lib/appConfig";
6
6
  import { createVerificationToken } from "../lib/emailVerification";
7
+ import { createMfaChallenge } from "../lib/mfaChallenge";
8
+ import { generateEmailOtpCode } from "./mfa";
9
+ async function createSessionWithRefreshToken(userId, sessionId, metadata) {
10
+ const rtConfig = getRefreshTokenConfig();
11
+ const expirySeconds = rtConfig ? getAccessTokenExpiry() : undefined;
12
+ const token = await signToken(userId, sessionId, expirySeconds);
13
+ while (await getActiveSessionCount(userId) >= getMaxSessions()) {
14
+ await evictOldestSession(userId);
15
+ }
16
+ await createSession(userId, token, sessionId, metadata);
17
+ let refreshToken;
18
+ if (rtConfig) {
19
+ refreshToken = crypto.randomUUID();
20
+ await setRefreshToken(sessionId, refreshToken);
21
+ }
22
+ return { token, refreshToken, sessionId };
23
+ }
24
+ /** Create a session for a user (used internally and by MFA verify). */
25
+ export const createSessionForUser = async (userId, metadata) => {
26
+ const sessionId = crypto.randomUUID();
27
+ return createSessionWithRefreshToken(userId, sessionId, metadata);
28
+ };
7
29
  export const register = async (identifier, password, metadata) => {
8
30
  const hashed = await Bun.password.hash(password);
9
31
  const adapter = getAuthAdapter();
@@ -12,11 +34,7 @@ export const register = async (identifier, password, metadata) => {
12
34
  if (role)
13
35
  await adapter.setRoles(user.id, [role]);
14
36
  const sessionId = crypto.randomUUID();
15
- const token = await signToken(user.id, sessionId);
16
- while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
17
- await evictOldestSession(user.id);
18
- }
19
- await createSession(user.id, token, sessionId, metadata);
37
+ const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
20
38
  const evConfig = getEmailVerificationConfig();
21
39
  if (evConfig && getPrimaryField() === "email") {
22
40
  try {
@@ -27,7 +45,7 @@ export const register = async (identifier, password, metadata) => {
27
45
  console.error("[email-verification] Failed to send verification email:", e);
28
46
  }
29
47
  }
30
- return { token, userId: user.id, email: identifier };
48
+ return { token, userId: user.id, email: identifier, refreshToken };
31
49
  };
32
50
  export const login = async (identifier, password, metadata) => {
33
51
  const adapter = getAuthAdapter();
@@ -36,11 +54,7 @@ export const login = async (identifier, password, metadata) => {
36
54
  if (!user || !(await Bun.password.verify(password, user.passwordHash))) {
37
55
  throw new HttpError(401, "Invalid credentials");
38
56
  }
39
- const sessionId = crypto.randomUUID();
40
- const token = await signToken(user.id, sessionId);
41
- while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
42
- await evictOldestSession(user.id);
43
- }
57
+ // Check email verification before MFA to avoid leaking MFA status to unverified users
44
58
  const fullUser = adapter.getUser ? await adapter.getUser(user.id) : null;
45
59
  const googleLinked = fullUser?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
46
60
  const evConfig = getEmailVerificationConfig();
@@ -49,11 +63,75 @@ export const login = async (identifier, password, metadata) => {
49
63
  if (evConfig.required && !verified) {
50
64
  throw new HttpError(403, "Email not verified");
51
65
  }
52
- await createSession(user.id, token, sessionId, metadata);
53
- return { token, userId: user.id, email: fullUser?.email, emailVerified: verified, googleLinked };
54
66
  }
55
- await createSession(user.id, token, sessionId, metadata);
56
- return { token, userId: user.id, email: fullUser?.email, googleLinked };
67
+ // Check MFA — if enabled, return challenge token instead of session
68
+ if (getMfaConfig() && adapter.isMfaEnabled && await adapter.isMfaEnabled(user.id)) {
69
+ const methods = adapter.getMfaMethods
70
+ ? await adapter.getMfaMethods(user.id)
71
+ : ["totp"];
72
+ // Auto-send email OTP if enabled
73
+ let emailOtpHash;
74
+ const emailOtpConfig = getMfaEmailOtpConfig();
75
+ if (methods.includes("emailOtp") && emailOtpConfig) {
76
+ const { code, hash } = generateEmailOtpCode();
77
+ emailOtpHash = hash;
78
+ const email = fullUser?.email;
79
+ if (email)
80
+ await emailOtpConfig.onSend(email, code);
81
+ }
82
+ const mfaToken = await createMfaChallenge(user.id, emailOtpHash);
83
+ return { token: "", userId: user.id, mfaRequired: true, mfaToken, mfaMethods: methods };
84
+ }
85
+ const sessionId = crypto.randomUUID();
86
+ const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
87
+ if (evConfig && getPrimaryField() === "email" && adapter.getEmailVerified) {
88
+ const verified = await adapter.getEmailVerified(user.id);
89
+ return { token, userId: user.id, email: fullUser?.email, emailVerified: verified, googleLinked, refreshToken };
90
+ }
91
+ return { token, userId: user.id, email: fullUser?.email, googleLinked, refreshToken };
92
+ };
93
+ export const refresh = async (refreshTokenValue) => {
94
+ const result = await getSessionByRefreshToken(refreshTokenValue);
95
+ if (!result) {
96
+ throw new HttpError(401, "Invalid or expired refresh token");
97
+ }
98
+ const { sessionId, userId, newRefreshToken } = result;
99
+ // If the returned newRefreshToken differs from what was sent, we're in a grace window replay.
100
+ // Return the current tokens without rotating again.
101
+ if (newRefreshToken !== refreshTokenValue) {
102
+ const accessToken = await signToken(userId, sessionId, getAccessTokenExpiry());
103
+ return { token: accessToken, refreshToken: newRefreshToken, userId };
104
+ }
105
+ // Normal rotation: generate new refresh + access tokens
106
+ const newRT = crypto.randomUUID();
107
+ const newAccessToken = await signToken(userId, sessionId, getAccessTokenExpiry());
108
+ await rotateRefreshToken(sessionId, newRT, newAccessToken);
109
+ return { token: newAccessToken, refreshToken: newRT, userId };
110
+ };
111
+ export const deleteAccount = async (userId, password) => {
112
+ const adapter = getAuthAdapter();
113
+ if (!adapter.deleteUser) {
114
+ throw new HttpError(501, "Auth adapter does not support deleteUser");
115
+ }
116
+ // Verify password for credential accounts
117
+ if (password) {
118
+ const user = adapter.getUser ? await adapter.getUser(userId) : null;
119
+ const email = user?.email;
120
+ if (email) {
121
+ const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
122
+ const found = await findFn(email);
123
+ if (found && !(await Bun.password.verify(password, found.passwordHash))) {
124
+ throw new HttpError(401, "Invalid password");
125
+ }
126
+ }
127
+ }
128
+ else if (adapter.hasPassword && await adapter.hasPassword(userId)) {
129
+ throw new HttpError(400, "Password is required to delete a credential account");
130
+ }
131
+ // Revoke all sessions
132
+ await deleteUserSessions(userId);
133
+ // Delete the user
134
+ await adapter.deleteUser(userId);
57
135
  };
58
136
  export const logout = async (token) => {
59
137
  if (token) {
@@ -0,0 +1,37 @@
1
+ export interface MfaSetupResult {
2
+ secret: string;
3
+ uri: string;
4
+ }
5
+ export declare const setupMfa: (userId: string) => Promise<MfaSetupResult>;
6
+ export declare const verifySetup: (userId: string, code: string) => Promise<string[]>;
7
+ export declare const verifyTotp: (userId: string, code: string) => Promise<boolean>;
8
+ export declare const verifyRecoveryCode: (userId: string, code: string) => Promise<boolean>;
9
+ export declare const disableMfa: (userId: string, code: string) => Promise<void>;
10
+ export declare const regenerateRecoveryCodes: (userId: string, code: string) => Promise<string[]>;
11
+ /** Generate a cryptographically random numeric OTP code. Returns { code, hash }. */
12
+ export declare const generateEmailOtpCode: (length?: number) => {
13
+ code: string;
14
+ hash: string;
15
+ };
16
+ /** Verify an email OTP code against a stored hash. */
17
+ export declare const verifyEmailOtp: (emailOtpHash: string, code: string) => boolean;
18
+ /**
19
+ * Initiate email OTP setup: sends a verification code to the user's email.
20
+ * Returns a setup challenge token that must be confirmed via confirmEmailOtp.
21
+ */
22
+ export declare const initiateEmailOtp: (userId: string) => Promise<string>;
23
+ /**
24
+ * Confirm email OTP setup: verifies the code sent during initiateEmailOtp.
25
+ * Enables email OTP as an MFA method. Returns recovery codes if MFA was not previously active.
26
+ */
27
+ export declare const confirmEmailOtp: (userId: string, setupToken: string, code: string) => Promise<string[] | null>;
28
+ /**
29
+ * Disable email OTP for a user.
30
+ * If TOTP is also enabled, requires a TOTP code. Otherwise requires password.
31
+ */
32
+ export declare const disableEmailOtp: (userId: string, params: {
33
+ code?: string;
34
+ password?: string;
35
+ }) => Promise<void>;
36
+ /** Get the MFA methods enabled for a user. */
37
+ export declare const getMfaMethods: (userId: string) => Promise<string[]>;
@@ -0,0 +1,276 @@
1
+ import { getAuthAdapter } from "../lib/authAdapter";
2
+ import { HttpError } from "../lib/HttpError";
3
+ import { getMfaIssuer, getMfaAlgorithm, getMfaDigits, getMfaPeriod, getMfaRecoveryCodeCount, getMfaEmailOtpConfig, getMfaEmailOtpCodeLength } from "../lib/appConfig";
4
+ import { createMfaChallenge } from "../lib/mfaChallenge";
5
+ // Lazy-load otpauth to keep it as an optional peer dependency
6
+ let _otpauth = null;
7
+ async function getOtpAuth() {
8
+ if (!_otpauth)
9
+ _otpauth = await import("otpauth");
10
+ return _otpauth;
11
+ }
12
+ function sha256(input) {
13
+ const hash = new Bun.CryptoHasher("sha256");
14
+ hash.update(input);
15
+ return hash.digest("hex");
16
+ }
17
+ function generateRandomCode(length) {
18
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous chars: I/1/O/0
19
+ let code = "";
20
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
21
+ for (let i = 0; i < length; i++) {
22
+ code += chars[bytes[i] % chars.length];
23
+ }
24
+ return code;
25
+ }
26
+ function generateRecoveryCodes() {
27
+ const count = getMfaRecoveryCodeCount();
28
+ const plainCodes = [];
29
+ const hashedCodes = [];
30
+ for (let i = 0; i < count; i++) {
31
+ const plain = generateRandomCode(8);
32
+ plainCodes.push(plain);
33
+ hashedCodes.push(sha256(plain));
34
+ }
35
+ return { plainCodes, hashedCodes };
36
+ }
37
+ export const setupMfa = async (userId) => {
38
+ const adapter = getAuthAdapter();
39
+ if (!adapter.setMfaSecret)
40
+ throw new HttpError(501, "Auth adapter does not support MFA");
41
+ const otpauth = await getOtpAuth();
42
+ const secret = new otpauth.Secret();
43
+ const totp = new otpauth.TOTP({
44
+ issuer: getMfaIssuer(),
45
+ label: userId,
46
+ algorithm: getMfaAlgorithm(),
47
+ digits: getMfaDigits(),
48
+ period: getMfaPeriod(),
49
+ secret,
50
+ });
51
+ // Store the secret but don't enable MFA yet — user must confirm with a code
52
+ await adapter.setMfaSecret(userId, secret.base32);
53
+ return {
54
+ secret: secret.base32,
55
+ uri: totp.toString(),
56
+ };
57
+ };
58
+ export const verifySetup = async (userId, code) => {
59
+ const adapter = getAuthAdapter();
60
+ if (!adapter.getMfaSecret || !adapter.setMfaEnabled || !adapter.setRecoveryCodes) {
61
+ throw new HttpError(501, "Auth adapter does not support MFA");
62
+ }
63
+ const secretStr = await adapter.getMfaSecret(userId);
64
+ if (!secretStr)
65
+ throw new HttpError(400, "MFA setup not initiated. Call POST /auth/mfa/setup first.");
66
+ const otpauth = await getOtpAuth();
67
+ const totp = new otpauth.TOTP({
68
+ issuer: getMfaIssuer(),
69
+ algorithm: getMfaAlgorithm(),
70
+ digits: getMfaDigits(),
71
+ period: getMfaPeriod(),
72
+ secret: otpauth.Secret.fromBase32(secretStr),
73
+ });
74
+ const delta = totp.validate({ token: code, window: 1 });
75
+ if (delta === null)
76
+ throw new HttpError(401, "Invalid TOTP code");
77
+ // Generate recovery codes (regenerates if enabling a second method)
78
+ const { plainCodes, hashedCodes } = generateRecoveryCodes();
79
+ await adapter.setRecoveryCodes(userId, hashedCodes);
80
+ await adapter.setMfaEnabled(userId, true);
81
+ // Add "totp" to mfaMethods
82
+ if (adapter.getMfaMethods && adapter.setMfaMethods) {
83
+ const methods = await adapter.getMfaMethods(userId);
84
+ if (!methods.includes("totp")) {
85
+ await adapter.setMfaMethods(userId, [...methods, "totp"]);
86
+ }
87
+ }
88
+ return plainCodes;
89
+ };
90
+ export const verifyTotp = async (userId, code) => {
91
+ const adapter = getAuthAdapter();
92
+ if (!adapter.getMfaSecret)
93
+ throw new HttpError(501, "Auth adapter does not support MFA");
94
+ const secretStr = await adapter.getMfaSecret(userId);
95
+ if (!secretStr)
96
+ return false;
97
+ const otpauth = await getOtpAuth();
98
+ const totp = new otpauth.TOTP({
99
+ issuer: getMfaIssuer(),
100
+ algorithm: getMfaAlgorithm(),
101
+ digits: getMfaDigits(),
102
+ period: getMfaPeriod(),
103
+ secret: otpauth.Secret.fromBase32(secretStr),
104
+ });
105
+ return totp.validate({ token: code, window: 1 }) !== null;
106
+ };
107
+ export const verifyRecoveryCode = async (userId, code) => {
108
+ const adapter = getAuthAdapter();
109
+ if (!adapter.getRecoveryCodes || !adapter.removeRecoveryCode)
110
+ return false;
111
+ const hashedCodes = await adapter.getRecoveryCodes(userId);
112
+ const hashedInput = sha256(code.toUpperCase());
113
+ const match = hashedCodes.find((h) => h === hashedInput);
114
+ if (!match)
115
+ return false;
116
+ await adapter.removeRecoveryCode(userId, match);
117
+ return true;
118
+ };
119
+ export const disableMfa = async (userId, code) => {
120
+ const adapter = getAuthAdapter();
121
+ if (!adapter.setMfaEnabled || !adapter.setMfaSecret || !adapter.setRecoveryCodes) {
122
+ throw new HttpError(501, "Auth adapter does not support MFA");
123
+ }
124
+ const valid = await verifyTotp(userId, code);
125
+ if (!valid)
126
+ throw new HttpError(401, "Invalid TOTP code");
127
+ await adapter.setMfaEnabled(userId, false);
128
+ await adapter.setMfaSecret(userId, null);
129
+ await adapter.setRecoveryCodes(userId, []);
130
+ // Clear all mfaMethods
131
+ if (adapter.setMfaMethods) {
132
+ await adapter.setMfaMethods(userId, []);
133
+ }
134
+ };
135
+ export const regenerateRecoveryCodes = async (userId, code) => {
136
+ const adapter = getAuthAdapter();
137
+ if (!adapter.setRecoveryCodes)
138
+ throw new HttpError(501, "Auth adapter does not support MFA");
139
+ const valid = await verifyTotp(userId, code);
140
+ if (!valid)
141
+ throw new HttpError(401, "Invalid TOTP code");
142
+ const { plainCodes, hashedCodes } = generateRecoveryCodes();
143
+ await adapter.setRecoveryCodes(userId, hashedCodes);
144
+ return plainCodes;
145
+ };
146
+ // ---------------------------------------------------------------------------
147
+ // Email OTP
148
+ // ---------------------------------------------------------------------------
149
+ /** Generate a cryptographically random numeric OTP code. Returns { code, hash }. */
150
+ export const generateEmailOtpCode = (length) => {
151
+ const len = length ?? getMfaEmailOtpCodeLength();
152
+ const bytes = crypto.getRandomValues(new Uint8Array(len));
153
+ let code = "";
154
+ for (let i = 0; i < len; i++) {
155
+ code += (bytes[i] % 10).toString();
156
+ }
157
+ return { code, hash: sha256(code) };
158
+ };
159
+ /** Verify an email OTP code against a stored hash. */
160
+ export const verifyEmailOtp = (emailOtpHash, code) => {
161
+ return sha256(code) === emailOtpHash;
162
+ };
163
+ /**
164
+ * Initiate email OTP setup: sends a verification code to the user's email.
165
+ * Returns a setup challenge token that must be confirmed via confirmEmailOtp.
166
+ */
167
+ export const initiateEmailOtp = async (userId) => {
168
+ const adapter = getAuthAdapter();
169
+ const emailOtpConfig = getMfaEmailOtpConfig();
170
+ if (!emailOtpConfig)
171
+ throw new HttpError(501, "Email OTP is not configured");
172
+ const user = adapter.getUser ? await adapter.getUser(userId) : null;
173
+ if (!user?.email)
174
+ throw new HttpError(400, "No email address on account");
175
+ const { code, hash } = generateEmailOtpCode();
176
+ await emailOtpConfig.onSend(user.email, code);
177
+ // Store the hash in a challenge token for verification
178
+ const setupToken = await createMfaChallenge(userId, hash);
179
+ return setupToken;
180
+ };
181
+ /**
182
+ * Confirm email OTP setup: verifies the code sent during initiateEmailOtp.
183
+ * Enables email OTP as an MFA method. Returns recovery codes if MFA was not previously active.
184
+ */
185
+ export const confirmEmailOtp = async (userId, setupToken, code) => {
186
+ const adapter = getAuthAdapter();
187
+ if (!adapter.setMfaEnabled || !adapter.setRecoveryCodes) {
188
+ throw new HttpError(501, "Auth adapter does not support MFA");
189
+ }
190
+ // Import consumeMfaChallenge here to avoid circular dependency issues at module level
191
+ const { consumeMfaChallenge } = await import("../lib/mfaChallenge");
192
+ const challenge = await consumeMfaChallenge(setupToken);
193
+ if (!challenge)
194
+ throw new HttpError(401, "Invalid or expired setup token");
195
+ if (challenge.userId !== userId)
196
+ throw new HttpError(401, "Token does not match user");
197
+ if (!challenge.emailOtpHash)
198
+ throw new HttpError(400, "Invalid setup token — no OTP hash");
199
+ if (!verifyEmailOtp(challenge.emailOtpHash, code)) {
200
+ throw new HttpError(401, "Invalid verification code");
201
+ }
202
+ // Check if MFA was already active
203
+ const wasEnabled = adapter.isMfaEnabled ? await adapter.isMfaEnabled(userId) : false;
204
+ // Add "emailOtp" to mfaMethods
205
+ if (adapter.getMfaMethods && adapter.setMfaMethods) {
206
+ const methods = await adapter.getMfaMethods(userId);
207
+ if (!methods.includes("emailOtp")) {
208
+ await adapter.setMfaMethods(userId, [...methods, "emailOtp"]);
209
+ }
210
+ }
211
+ await adapter.setMfaEnabled(userId, true);
212
+ // Generate recovery codes if MFA was not previously active
213
+ if (!wasEnabled) {
214
+ const { plainCodes, hashedCodes } = generateRecoveryCodes();
215
+ await adapter.setRecoveryCodes(userId, hashedCodes);
216
+ return plainCodes;
217
+ }
218
+ // Regenerate recovery codes when adding a second method
219
+ const { plainCodes, hashedCodes } = generateRecoveryCodes();
220
+ await adapter.setRecoveryCodes(userId, hashedCodes);
221
+ return plainCodes;
222
+ };
223
+ /**
224
+ * Disable email OTP for a user.
225
+ * If TOTP is also enabled, requires a TOTP code. Otherwise requires password.
226
+ */
227
+ export const disableEmailOtp = async (userId, params) => {
228
+ const adapter = getAuthAdapter();
229
+ if (!adapter.setMfaEnabled)
230
+ throw new HttpError(501, "Auth adapter does not support MFA");
231
+ // Get current methods
232
+ const methods = adapter.getMfaMethods ? await adapter.getMfaMethods(userId) : [];
233
+ const hasTotpEnabled = methods.includes("totp");
234
+ // Verify identity
235
+ if (hasTotpEnabled) {
236
+ if (!params.code)
237
+ throw new HttpError(400, "TOTP code required to disable email OTP");
238
+ const valid = await verifyTotp(userId, params.code);
239
+ if (!valid)
240
+ throw new HttpError(401, "Invalid TOTP code");
241
+ }
242
+ else {
243
+ if (!params.password)
244
+ throw new HttpError(400, "Password required to disable email OTP");
245
+ // Verify password — look up the user's hash and compare
246
+ const user = adapter.findByIdentifier
247
+ ? await adapter.findByIdentifier((await adapter.getUser?.(userId))?.email ?? "")
248
+ : await adapter.findByEmail((await adapter.getUser?.(userId))?.email ?? "");
249
+ if (!user)
250
+ throw new HttpError(404, "User not found");
251
+ const valid = await Bun.password.verify(params.password, user.passwordHash);
252
+ if (!valid)
253
+ throw new HttpError(401, "Invalid password");
254
+ }
255
+ // Remove "emailOtp" from methods
256
+ if (adapter.setMfaMethods) {
257
+ const updated = methods.filter((m) => m !== "emailOtp");
258
+ await adapter.setMfaMethods(userId, updated);
259
+ // If no methods remain, disable MFA entirely
260
+ if (updated.length === 0) {
261
+ await adapter.setMfaEnabled(userId, false);
262
+ if (adapter.setRecoveryCodes)
263
+ await adapter.setRecoveryCodes(userId, []);
264
+ }
265
+ }
266
+ };
267
+ /** Get the MFA methods enabled for a user. */
268
+ export const getMfaMethods = async (userId) => {
269
+ const adapter = getAuthAdapter();
270
+ if (adapter.getMfaMethods)
271
+ return adapter.getMfaMethods(userId);
272
+ // Backward compat
273
+ if (adapter.isMfaEnabled && await adapter.isMfaEnabled(userId))
274
+ return ["totp"];
275
+ return [];
276
+ };
@@ -0,0 +1,35 @@
1
+ ## Adding Middleware
2
+
3
+ ### Global (runs on every request)
4
+
5
+ Pass via `middleware` config — injected after `identify`, before route matching:
6
+
7
+ ```ts
8
+ await createServer({
9
+ routesDir: import.meta.dir + "/routes",
10
+ app: { name: "My App", version: "1.0.0" },
11
+ middleware: [myMiddleware],
12
+ });
13
+ ```
14
+
15
+ Write it using core's exported types:
16
+
17
+ ```ts
18
+ // src/middleware/tenant.ts
19
+ import type { MiddlewareHandler } from "hono";
20
+ import type { AppEnv } from "@lastshotlabs/bunshot";
21
+
22
+ export const tenantMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
23
+ // c.get("userId") is available — identify has already run
24
+ await next();
25
+ };
26
+ ```
27
+
28
+ ### Per-route
29
+
30
+ ```ts
31
+ import { userAuth, rateLimit } from "@lastshotlabs/bunshot";
32
+
33
+ router.use("/admin", userAuth);
34
+ router.use("/admin", rateLimit({ windowMs: 60_000, max: 10 }));
35
+ ```
@@ -0,0 +1,125 @@
1
+ ## Adding Models
2
+
3
+ Import `appConnection` and register models on it. This ensures your models use the correct connection whether you're on a single DB or a separate tenant DB.
4
+
5
+ `appConnection` is a lazy proxy — calling `.model()` at the top level works fine even before `connectMongo()` has been called. Mongoose buffers any queries until the connection is established.
6
+
7
+ ```ts
8
+ // src/models/Product.ts
9
+ import { appConnection } from "@lastshotlabs/bunshot";
10
+ import { Schema } from "mongoose";
11
+ import type { HydratedDocument } from "mongoose";
12
+
13
+ interface IProduct {
14
+ name: string;
15
+ price: number;
16
+ }
17
+
18
+ export type ProductDocument = HydratedDocument<IProduct>;
19
+
20
+ const ProductSchema = new Schema<IProduct>({
21
+ name: { type: String, required: true },
22
+ price: { type: Number, required: true },
23
+ }, { timestamps: true });
24
+
25
+ export const Product = appConnection.model<IProduct>("Product", ProductSchema);
26
+ ```
27
+
28
+ > **Note:** Import types (`HydratedDocument`, `Schema`, etc.) directly from `"mongoose"` — the `appConnection` and `mongoose` exports from bunshot are runtime proxies and cannot be used as TypeScript namespaces.
29
+
30
+ ### Zod as Single Source of Truth
31
+
32
+ If you use Zod schemas for your OpenAPI spec (via `createRoute` or `modelSchemas`), you can derive your Mongoose schemas and DTO mappers from those same Zod definitions — so each entity is defined **once**.
33
+
34
+ #### `zodToMongoose` — Zod → Mongoose SchemaDefinition
35
+
36
+ Converts a Zod object schema into a Mongoose field definition. Business fields are auto-converted; DB-specific concerns (ObjectId refs, type overrides, subdocuments) are declared via config. The `id` field is automatically excluded since Mongoose provides `_id`.
37
+
38
+ ```ts
39
+ import { appConnection, zodToMongoose } from "@lastshotlabs/bunshot";
40
+ import { Schema, type HydratedDocument } from "mongoose";
41
+ import { ProductSchema } from "../schemas/product"; // your Zod schema
42
+ import type { ProductDto } from "../schemas/product";
43
+
44
+ // DB interface derives from Zod DTO type
45
+ interface IProduct extends Omit<ProductDto, "id" | "categoryId"> {
46
+ user: Types.ObjectId;
47
+ category: Types.ObjectId;
48
+ }
49
+
50
+ const ProductMongoSchema = new Schema<IProduct>(
51
+ zodToMongoose(ProductSchema, {
52
+ dbFields: {
53
+ user: { type: Schema.Types.ObjectId, ref: "UserProfile", required: true },
54
+ },
55
+ refs: {
56
+ categoryId: { dbField: "category", ref: "Category" },
57
+ },
58
+ typeOverrides: {
59
+ createdAt: { type: Date, required: true },
60
+ },
61
+ }) as Record<string, unknown>,
62
+ { timestamps: true }
63
+ );
64
+
65
+ export type ProductDocument = HydratedDocument<IProduct>;
66
+ export const Product = appConnection.model<IProduct>("Product", ProductMongoSchema);
67
+ ```
68
+
69
+ **Config options:**
70
+
71
+ | Option | Description |
72
+ |---|---|
73
+ | `dbFields` | Fields that exist only in the DB, not in the API schema (e.g., `user` ObjectId ref) |
74
+ | `refs` | API fields that map to ObjectId refs: `{ accountId: { dbField: "account", ref: "Account" } }` |
75
+ | `typeOverrides` | Override the auto-converted Mongoose type for a field (e.g., Zod `z.string()` for dates → Mongoose `Date`) |
76
+ | `subdocSchemas` | Subdocument array fields: `{ items: mongooseSubSchema }` |
77
+
78
+ **Auto-conversion mapping:**
79
+
80
+ | Zod type | Mongoose type |
81
+ |---|---|
82
+ | `z.string()` | `String` |
83
+ | `z.number()` | `Number` |
84
+ | `z.boolean()` | `Boolean` |
85
+ | `z.date()` | `Date` |
86
+ | `z.enum([...])` | `String` with `enum` |
87
+ | `.nullable()` / `.optional()` | `required: false` |
88
+
89
+ #### `createDtoMapper` — Zod → toDto mapper
90
+
91
+ Creates a generic `toDto` function from a Zod schema. The schema defines which fields exist in the DTO; the config declares how to transform DB-specific types.
92
+
93
+ ```ts
94
+ import { createDtoMapper } from "@lastshotlabs/bunshot";
95
+ import { ProductSchema, type ProductDto } from "../schemas/product";
96
+
97
+ const toDto = createDtoMapper<ProductDto>(ProductSchema, {
98
+ refs: { category: "categoryId" }, // ObjectId ref → string, with rename
99
+ dates: ["createdAt"], // Date → ISO string
100
+ });
101
+
102
+ // Use it
103
+ const product = await Product.findOne({ _id: id });
104
+ return product ? toDto(product) : null;
105
+ ```
106
+
107
+ **Auto-handled transforms:**
108
+
109
+ | Transform | Description |
110
+ |---|---|
111
+ | `_id` → `id` | Always converted via `.toString()` |
112
+ | `refs` | ObjectId fields → string (`.toString()`), with DB→API field renaming |
113
+ | `dates` | `Date` objects → ISO strings (`.toISOString()`) |
114
+ | `subdocs` | Array fields mapped with a sub-mapper (for nested documents) |
115
+ | nullable/optional | `undefined` → `null` coercion (based on Zod schema) |
116
+ | everything else | Passthrough |
117
+
118
+ **Subdocument example:**
119
+
120
+ ```ts
121
+ const itemToDto = createDtoMapper<TemplateItemDto>(TemplateItemSchema);
122
+ const toDto = createDtoMapper<TemplateDto>(TemplateSchema, {
123
+ subdocs: { items: itemToDto },
124
+ });
125
+ ```
@@ -0,0 +1,13 @@
1
+ ## Adding Models
2
+
3
+ Import `appConnection` and register Mongoose models on it. `appConnection` is a lazy proxy — `.model()` works before `connectMongo()` has been called.
4
+
5
+ ```ts
6
+ import { appConnection } from "@lastshotlabs/bunshot";
7
+ import { Schema, type HydratedDocument } from "mongoose";
8
+
9
+ const ProductSchema = new Schema({ name: String, price: Number }, { timestamps: true });
10
+ export const Product = appConnection.model("Product", ProductSchema);
11
+ ```
12
+
13
+ Bunshot also provides `zodToMongoose` (Zod -> Mongoose schema conversion) and `createDtoMapper` (DB document -> API DTO) to use Zod as the single source of truth for your models and OpenAPI spec.