@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
@@ -10,6 +10,10 @@ export declare const memoryGetUserSessions: (userId: string) => SessionInfo[];
10
10
  export declare const memoryGetActiveSessionCount: (userId: string) => number;
11
11
  export declare const memoryEvictOldestSession: (userId: string) => void;
12
12
  export declare const memoryUpdateSessionLastActive: (sessionId: string) => void;
13
+ export declare const memorySetRefreshToken: (sessionId: string, refreshToken: string) => void;
14
+ import type { RefreshResult } from "../lib/session";
15
+ export declare const memoryGetSessionByRefreshToken: (refreshToken: string) => RefreshResult | null;
16
+ export declare const memoryRotateRefreshToken: (sessionId: string, newRefreshToken: string, newAccessToken: string) => void;
13
17
  export declare const memoryStoreOAuthState: (state: string, codeVerifier?: string, linkUserId?: string) => void;
14
18
  export declare const memoryConsumeOAuthState: (state: string) => {
15
19
  codeVerifier?: string;
@@ -4,16 +4,20 @@ const _users = new Map();
4
4
  const _byEmail = new Map();
5
5
  const _sessions = new Map(); // sessionId → session
6
6
  const _userSessionIds = new Map(); // userId → Set<sessionId>
7
+ const _refreshTokenIndex = new Map(); // refreshToken → sessionId
7
8
  const _oauthStates = new Map();
8
9
  const _cache = new Map();
9
10
  const _verificationTokens = new Map();
10
11
  const _resetTokens = new Map();
12
+ const _tenantRoles = new Map(); // "userId:tenantId" → roles
11
13
  /** Reset all in-memory state. Useful for test isolation. */
12
14
  export const clearMemoryStore = () => {
13
15
  _users.clear();
14
16
  _byEmail.clear();
15
17
  _sessions.clear();
16
18
  _userSessionIds.clear();
19
+ _refreshTokenIndex.clear();
20
+ _tenantRoles.clear();
17
21
  _oauthStates.clear();
18
22
  _cache.clear();
19
23
  _verificationTokens.clear();
@@ -37,7 +41,7 @@ export const memoryAuthAdapter = {
37
41
  if (_byEmail.has(normalised))
38
42
  throw new HttpError(409, "Email already registered");
39
43
  const id = crypto.randomUUID();
40
- const user = { id, email: normalised, passwordHash, providerIds: [], roles: [], emailVerified: false };
44
+ const user = { id, email: normalised, passwordHash, providerIds: [], roles: [], emailVerified: false, mfaSecret: null, mfaEnabled: false, recoveryCodes: [], mfaMethods: [] };
41
45
  _users.set(id, user);
42
46
  _byEmail.set(normalised, id);
43
47
  return { id };
@@ -63,7 +67,7 @@ export const memoryAuthAdapter = {
63
67
  }
64
68
  const id = crypto.randomUUID();
65
69
  const email = profile.email ? profile.email.toLowerCase() : null;
66
- const user = { id, email, passwordHash: null, providerIds: [key], roles: [], emailVerified: false };
70
+ const user = { id, email, passwordHash: null, providerIds: [key], roles: [], emailVerified: false, mfaSecret: null, mfaEnabled: false, recoveryCodes: [], mfaMethods: [] };
67
71
  _users.set(id, user);
68
72
  if (email)
69
73
  _byEmail.set(email, id);
@@ -132,6 +136,78 @@ export const memoryAuthAdapter = {
132
136
  async getEmailVerified(userId) {
133
137
  return _users.get(userId)?.emailVerified ?? false;
134
138
  },
139
+ async deleteUser(userId) {
140
+ const user = _users.get(userId);
141
+ if (user?.email)
142
+ _byEmail.delete(user.email);
143
+ _users.delete(userId);
144
+ },
145
+ async hasPassword(userId) {
146
+ return !!_users.get(userId)?.passwordHash;
147
+ },
148
+ async setMfaSecret(userId, secret) {
149
+ const user = _users.get(userId);
150
+ if (user)
151
+ user.mfaSecret = secret;
152
+ },
153
+ async getMfaSecret(userId) {
154
+ return _users.get(userId)?.mfaSecret ?? null;
155
+ },
156
+ async isMfaEnabled(userId) {
157
+ return _users.get(userId)?.mfaEnabled ?? false;
158
+ },
159
+ async setMfaEnabled(userId, enabled) {
160
+ const user = _users.get(userId);
161
+ if (user)
162
+ user.mfaEnabled = enabled;
163
+ },
164
+ async setRecoveryCodes(userId, codes) {
165
+ const user = _users.get(userId);
166
+ if (user)
167
+ user.recoveryCodes = [...codes];
168
+ },
169
+ async getRecoveryCodes(userId) {
170
+ return _users.get(userId)?.recoveryCodes ?? [];
171
+ },
172
+ async removeRecoveryCode(userId, code) {
173
+ const user = _users.get(userId);
174
+ if (user)
175
+ user.recoveryCodes = user.recoveryCodes.filter((c) => c !== code);
176
+ },
177
+ async getMfaMethods(userId) {
178
+ const user = _users.get(userId);
179
+ if (!user)
180
+ return [];
181
+ // Backward compat: if mfaEnabled but no methods recorded, assume TOTP
182
+ if (user.mfaMethods.length === 0 && user.mfaEnabled)
183
+ return ["totp"];
184
+ return [...user.mfaMethods];
185
+ },
186
+ async setMfaMethods(userId, methods) {
187
+ const user = _users.get(userId);
188
+ if (user)
189
+ user.mfaMethods = [...methods];
190
+ },
191
+ async getTenantRoles(userId, tenantId) {
192
+ return _tenantRoles.get(`${userId}:${tenantId}`) ?? [];
193
+ },
194
+ async setTenantRoles(userId, tenantId, roles) {
195
+ _tenantRoles.set(`${userId}:${tenantId}`, [...roles]);
196
+ },
197
+ async addTenantRole(userId, tenantId, role) {
198
+ const key = `${userId}:${tenantId}`;
199
+ const current = _tenantRoles.get(key) ?? [];
200
+ if (!current.includes(role)) {
201
+ _tenantRoles.set(key, [...current, role]);
202
+ }
203
+ },
204
+ async removeTenantRole(userId, tenantId, role) {
205
+ const key = `${userId}:${tenantId}`;
206
+ const current = _tenantRoles.get(key);
207
+ if (current) {
208
+ _tenantRoles.set(key, current.filter((r) => r !== role));
209
+ }
210
+ },
135
211
  };
136
212
  // ---------------------------------------------------------------------------
137
213
  // Session helpers (used by src/lib/session.ts)
@@ -160,8 +236,16 @@ export const memoryDeleteSession = (sessionId) => {
160
236
  const entry = _sessions.get(sessionId);
161
237
  if (!entry)
162
238
  return;
239
+ // Clean up refresh token reverse-lookup keys
240
+ if (entry.refreshToken)
241
+ _refreshTokenIndex.delete(entry.refreshToken);
242
+ if (entry.prevRefreshToken)
243
+ _refreshTokenIndex.delete(entry.prevRefreshToken);
163
244
  if (getPersistSessionMetadata()) {
164
245
  entry.token = null;
246
+ entry.refreshToken = null;
247
+ entry.prevRefreshToken = null;
248
+ entry.prevTokenExpiresAt = null;
165
249
  }
166
250
  else {
167
251
  _sessions.delete(sessionId);
@@ -231,6 +315,51 @@ export const memoryUpdateSessionLastActive = (sessionId) => {
231
315
  if (entry)
232
316
  entry.lastActiveAt = Date.now();
233
317
  };
318
+ export const memorySetRefreshToken = (sessionId, refreshToken) => {
319
+ const entry = _sessions.get(sessionId);
320
+ if (!entry)
321
+ return;
322
+ entry.refreshToken = refreshToken;
323
+ _refreshTokenIndex.set(refreshToken, sessionId);
324
+ };
325
+ import { getRotationGraceSeconds } from "../lib/appConfig";
326
+ export const memoryGetSessionByRefreshToken = (refreshToken) => {
327
+ const sessionId = _refreshTokenIndex.get(refreshToken);
328
+ if (!sessionId)
329
+ return null;
330
+ const entry = _sessions.get(sessionId);
331
+ if (!entry)
332
+ return null;
333
+ // Current refresh token matches
334
+ if (entry.refreshToken === refreshToken) {
335
+ return { sessionId: entry.sessionId, userId: entry.userId, newRefreshToken: refreshToken };
336
+ }
337
+ // Check grace window
338
+ if (entry.prevRefreshToken === refreshToken && entry.prevTokenExpiresAt && entry.prevTokenExpiresAt > Date.now()) {
339
+ return { sessionId: entry.sessionId, userId: entry.userId, newRefreshToken: entry.refreshToken };
340
+ }
341
+ // Grace window expired — theft detected, invalidate session
342
+ if (entry.prevRefreshToken === refreshToken) {
343
+ memoryDeleteSession(sessionId);
344
+ return null;
345
+ }
346
+ return null;
347
+ };
348
+ export const memoryRotateRefreshToken = (sessionId, newRefreshToken, newAccessToken) => {
349
+ const entry = _sessions.get(sessionId);
350
+ if (!entry)
351
+ return;
352
+ const graceSeconds = getRotationGraceSeconds();
353
+ // Move current to prev
354
+ const oldRefreshToken = entry.refreshToken;
355
+ entry.prevRefreshToken = oldRefreshToken;
356
+ entry.prevTokenExpiresAt = Date.now() + graceSeconds * 1000;
357
+ entry.refreshToken = newRefreshToken;
358
+ entry.token = newAccessToken;
359
+ // Update reverse-lookup index
360
+ _refreshTokenIndex.set(newRefreshToken, sessionId);
361
+ // Old token stays in index during grace window — cleaned up on next lookup or session delete
362
+ };
234
363
  // ---------------------------------------------------------------------------
235
364
  // OAuth state helpers (used by src/lib/oauth.ts)
236
365
  // ---------------------------------------------------------------------------
@@ -1,4 +1,5 @@
1
1
  import { AuthUser } from "../models/AuthUser";
2
+ import { TenantRole } from "../models/TenantRole";
2
3
  import { HttpError } from "../lib/HttpError";
3
4
  export const mongoAuthAdapter = {
4
5
  async findByEmail(email) {
@@ -90,4 +91,59 @@ export const mongoAuthAdapter = {
90
91
  const user = await AuthUser.findById(userId, "emailVerified").lean();
91
92
  return user?.emailVerified ?? false;
92
93
  },
94
+ async deleteUser(userId) {
95
+ await AuthUser.findByIdAndDelete(userId);
96
+ },
97
+ async hasPassword(userId) {
98
+ const user = await AuthUser.findById(userId, "password").lean();
99
+ return !!user?.password;
100
+ },
101
+ async setMfaSecret(userId, secret) {
102
+ await AuthUser.findByIdAndUpdate(userId, { mfaSecret: secret });
103
+ },
104
+ async getMfaSecret(userId) {
105
+ const user = await AuthUser.findById(userId, "mfaSecret").lean();
106
+ return user?.mfaSecret ?? null;
107
+ },
108
+ async isMfaEnabled(userId) {
109
+ const user = await AuthUser.findById(userId, "mfaEnabled").lean();
110
+ return user?.mfaEnabled ?? false;
111
+ },
112
+ async setMfaEnabled(userId, enabled) {
113
+ await AuthUser.findByIdAndUpdate(userId, { mfaEnabled: enabled });
114
+ },
115
+ async setRecoveryCodes(userId, codes) {
116
+ await AuthUser.findByIdAndUpdate(userId, { recoveryCodes: codes });
117
+ },
118
+ async getRecoveryCodes(userId) {
119
+ const user = await AuthUser.findById(userId, "recoveryCodes").lean();
120
+ return user?.recoveryCodes ?? [];
121
+ },
122
+ async removeRecoveryCode(userId, code) {
123
+ await AuthUser.findByIdAndUpdate(userId, { $pull: { recoveryCodes: code } });
124
+ },
125
+ async getMfaMethods(userId) {
126
+ const user = await AuthUser.findById(userId, "mfaMethods mfaEnabled").lean();
127
+ const methods = user?.mfaMethods ?? [];
128
+ // Backward compat: if mfaEnabled but no methods recorded, assume TOTP
129
+ if (methods.length === 0 && user?.mfaEnabled)
130
+ return ["totp"];
131
+ return methods;
132
+ },
133
+ async setMfaMethods(userId, methods) {
134
+ await AuthUser.findByIdAndUpdate(userId, { mfaMethods: methods });
135
+ },
136
+ async getTenantRoles(userId, tenantId) {
137
+ const doc = await TenantRole.findOne({ userId, tenantId }, "roles").lean();
138
+ return doc?.roles ?? [];
139
+ },
140
+ async setTenantRoles(userId, tenantId, roles) {
141
+ await TenantRole.findOneAndUpdate({ userId, tenantId }, { roles }, { upsert: true });
142
+ },
143
+ async addTenantRole(userId, tenantId, role) {
144
+ await TenantRole.findOneAndUpdate({ userId, tenantId }, { $addToSet: { roles: role } }, { upsert: true });
145
+ },
146
+ async removeTenantRole(userId, tenantId, role) {
147
+ await TenantRole.findOneAndUpdate({ userId, tenantId }, { $pull: { roles: role } });
148
+ },
93
149
  };
@@ -1,5 +1,7 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import type { AuthAdapter } from "../lib/authAdapter";
2
3
  export declare const setSqliteDb: (path: string) => void;
4
+ export declare function getDb(): Database;
3
5
  export declare const sqliteAuthAdapter: AuthAdapter;
4
6
  import type { SessionMetadata, SessionInfo } from "../lib/session";
5
7
  export declare const sqliteCreateSession: (userId: string, token: string, sessionId: string, metadata?: SessionMetadata) => void;
@@ -9,6 +11,10 @@ export declare const sqliteGetUserSessions: (userId: string) => SessionInfo[];
9
11
  export declare const sqliteGetActiveSessionCount: (userId: string) => number;
10
12
  export declare const sqliteEvictOldestSession: (userId: string) => void;
11
13
  export declare const sqliteUpdateSessionLastActive: (sessionId: string) => void;
14
+ import type { RefreshResult } from "../lib/session";
15
+ export declare const sqliteSetRefreshToken: (sessionId: string, refreshToken: string) => void;
16
+ export declare const sqliteGetSessionByRefreshToken: (refreshToken: string) => RefreshResult | null;
17
+ export declare const sqliteRotateRefreshToken: (sessionId: string, newRefreshToken: string, newAccessToken: string) => void;
12
18
  export declare const sqliteStoreOAuthState: (state: string, codeVerifier?: string, linkUserId?: string) => void;
13
19
  export declare const sqliteConsumeOAuthState: (state: string) => {
14
20
  codeVerifier?: string;
@@ -10,7 +10,7 @@ export const setSqliteDb = (path) => {
10
10
  _db.run("PRAGMA foreign_keys = ON");
11
11
  initSchema(_db);
12
12
  };
13
- function getDb() {
13
+ export function getDb() {
14
14
  if (!_db)
15
15
  throw new Error("SQLite not initialized — call setSqliteDb(path) before using sqliteAuthAdapter or sessionStore: 'sqlite'");
16
16
  return _db;
@@ -32,6 +32,23 @@ function initSchema(db) {
32
32
  db.run("ALTER TABLE users ADD COLUMN emailVerified INTEGER NOT NULL DEFAULT 0");
33
33
  }
34
34
  catch { /* already exists */ }
35
+ // Add MFA columns to pre-existing databases
36
+ try {
37
+ db.run("ALTER TABLE users ADD COLUMN mfaSecret TEXT");
38
+ }
39
+ catch { /* already exists */ }
40
+ try {
41
+ db.run("ALTER TABLE users ADD COLUMN mfaEnabled INTEGER NOT NULL DEFAULT 0");
42
+ }
43
+ catch { /* already exists */ }
44
+ try {
45
+ db.run("ALTER TABLE users ADD COLUMN recoveryCodes TEXT NOT NULL DEFAULT '[]'");
46
+ }
47
+ catch { /* already exists */ }
48
+ try {
49
+ db.run("ALTER TABLE users ADD COLUMN mfaMethods TEXT NOT NULL DEFAULT '[]'");
50
+ }
51
+ catch { /* already exists */ }
35
52
  // Migrate legacy sessions table (userId PK) to new multi-session schema (sessionId PK)
36
53
  try {
37
54
  db.run("ALTER TABLE sessions RENAME TO sessions_legacy");
@@ -48,6 +65,20 @@ function initSchema(db) {
48
65
  userAgent TEXT
49
66
  )`);
50
67
  db.run("CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId)");
68
+ // Add refresh token columns to pre-existing databases
69
+ try {
70
+ db.run("ALTER TABLE sessions ADD COLUMN refreshToken TEXT");
71
+ }
72
+ catch { /* already exists */ }
73
+ try {
74
+ db.run("ALTER TABLE sessions ADD COLUMN prevRefreshToken TEXT");
75
+ }
76
+ catch { /* already exists */ }
77
+ try {
78
+ db.run("ALTER TABLE sessions ADD COLUMN prevTokenExpiresAt INTEGER");
79
+ }
80
+ catch { /* already exists */ }
81
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_refreshToken ON sessions(refreshToken) WHERE refreshToken IS NOT NULL");
51
82
  db.run(`CREATE TABLE IF NOT EXISTS oauth_states (
52
83
  state TEXT PRIMARY KEY,
53
84
  codeVerifier TEXT,
@@ -71,6 +102,13 @@ function initSchema(db) {
71
102
  email TEXT NOT NULL,
72
103
  expiresAt INTEGER NOT NULL
73
104
  )`);
105
+ db.run(`CREATE TABLE IF NOT EXISTS tenant_roles (
106
+ userId TEXT NOT NULL,
107
+ tenantId TEXT NOT NULL,
108
+ role TEXT NOT NULL,
109
+ PRIMARY KEY (userId, tenantId, role)
110
+ )`);
111
+ db.run("CREATE INDEX IF NOT EXISTS idx_tenant_roles_tenant ON tenant_roles(tenantId)");
74
112
  }
75
113
  // ---------------------------------------------------------------------------
76
114
  // Auth adapter
@@ -177,6 +215,74 @@ export const sqliteAuthAdapter = {
177
215
  const row = getDb().query("SELECT emailVerified FROM users WHERE id = ?").get(userId);
178
216
  return row?.emailVerified === 1;
179
217
  },
218
+ async deleteUser(userId) {
219
+ getDb().run("DELETE FROM users WHERE id = ?", [userId]);
220
+ },
221
+ async hasPassword(userId) {
222
+ const row = getDb().query("SELECT passwordHash FROM users WHERE id = ?").get(userId);
223
+ return !!row?.passwordHash;
224
+ },
225
+ async setMfaSecret(userId, secret) {
226
+ getDb().run("UPDATE users SET mfaSecret = ? WHERE id = ?", [secret, userId]);
227
+ },
228
+ async getMfaSecret(userId) {
229
+ const row = getDb().query("SELECT mfaSecret FROM users WHERE id = ?").get(userId);
230
+ return row?.mfaSecret ?? null;
231
+ },
232
+ async isMfaEnabled(userId) {
233
+ const row = getDb().query("SELECT mfaEnabled FROM users WHERE id = ?").get(userId);
234
+ return row?.mfaEnabled === 1;
235
+ },
236
+ async setMfaEnabled(userId, enabled) {
237
+ getDb().run("UPDATE users SET mfaEnabled = ? WHERE id = ?", [enabled ? 1 : 0, userId]);
238
+ },
239
+ async setRecoveryCodes(userId, codes) {
240
+ getDb().run("UPDATE users SET recoveryCodes = ? WHERE id = ?", [JSON.stringify(codes), userId]);
241
+ },
242
+ async getRecoveryCodes(userId) {
243
+ const row = getDb().query("SELECT recoveryCodes FROM users WHERE id = ?").get(userId);
244
+ return row?.recoveryCodes ? JSON.parse(row.recoveryCodes) : [];
245
+ },
246
+ async removeRecoveryCode(userId, code) {
247
+ const current = await sqliteAuthAdapter.getRecoveryCodes(userId);
248
+ const idx = current.indexOf(code);
249
+ if (idx !== -1) {
250
+ current.splice(idx, 1);
251
+ await sqliteAuthAdapter.setRecoveryCodes(userId, current);
252
+ }
253
+ },
254
+ async getMfaMethods(userId) {
255
+ const row = getDb().query("SELECT mfaMethods, mfaEnabled FROM users WHERE id = ?").get(userId);
256
+ const methods = row?.mfaMethods ? JSON.parse(row.mfaMethods) : [];
257
+ // Backward compat: if mfaEnabled but no methods recorded, assume TOTP
258
+ if (methods.length === 0 && row?.mfaEnabled === 1)
259
+ return ["totp"];
260
+ return methods;
261
+ },
262
+ async setMfaMethods(userId, methods) {
263
+ getDb().run("UPDATE users SET mfaMethods = ? WHERE id = ?", [JSON.stringify(methods), userId]);
264
+ },
265
+ async getTenantRoles(userId, tenantId) {
266
+ const rows = getDb().query("SELECT role FROM tenant_roles WHERE userId = ? AND tenantId = ?").all(userId, tenantId);
267
+ return rows.map((r) => r.role);
268
+ },
269
+ async setTenantRoles(userId, tenantId, roles) {
270
+ const db = getDb();
271
+ db.run("DELETE FROM tenant_roles WHERE userId = ? AND tenantId = ?", [userId, tenantId]);
272
+ const stmt = db.prepare("INSERT INTO tenant_roles (userId, tenantId, role) VALUES (?, ?, ?)");
273
+ for (const role of roles) {
274
+ stmt.run(userId, tenantId, role);
275
+ }
276
+ },
277
+ async addTenantRole(userId, tenantId, role) {
278
+ try {
279
+ getDb().run("INSERT INTO tenant_roles (userId, tenantId, role) VALUES (?, ?, ?)", [userId, tenantId, role]);
280
+ }
281
+ catch { /* already exists */ }
282
+ },
283
+ async removeTenantRole(userId, tenantId, role) {
284
+ getDb().run("DELETE FROM tenant_roles WHERE userId = ? AND tenantId = ? AND role = ?", [userId, tenantId, role]);
285
+ },
180
286
  };
181
287
  import { getPersistSessionMetadata, getIncludeInactiveSessions } from "../lib/appConfig";
182
288
  const SESSION_TTL_MS = 60 * 60 * 24 * 7 * 1000; // 7 days
@@ -193,7 +299,7 @@ export const sqliteGetSession = (sessionId) => {
193
299
  };
194
300
  export const sqliteDeleteSession = (sessionId) => {
195
301
  if (getPersistSessionMetadata()) {
196
- getDb().run("UPDATE sessions SET token = NULL WHERE sessionId = ?", [sessionId]);
302
+ getDb().run("UPDATE sessions SET token = NULL, refreshToken = NULL, prevRefreshToken = NULL, prevTokenExpiresAt = NULL WHERE sessionId = ?", [sessionId]);
197
303
  }
198
304
  else {
199
305
  getDb().run("DELETE FROM sessions WHERE sessionId = ?", [sessionId]);
@@ -236,6 +342,35 @@ export const sqliteEvictOldestSession = (userId) => {
236
342
  export const sqliteUpdateSessionLastActive = (sessionId) => {
237
343
  getDb().run("UPDATE sessions SET lastActiveAt = ? WHERE sessionId = ?", [Date.now(), sessionId]);
238
344
  };
345
+ import { getRotationGraceSeconds } from "../lib/appConfig";
346
+ export const sqliteSetRefreshToken = (sessionId, refreshToken) => {
347
+ getDb().run("UPDATE sessions SET refreshToken = ? WHERE sessionId = ?", [refreshToken, sessionId]);
348
+ };
349
+ export const sqliteGetSessionByRefreshToken = (refreshToken) => {
350
+ const db = getDb();
351
+ // Check current refresh token
352
+ let row = db.query("SELECT sessionId, userId, refreshToken FROM sessions WHERE refreshToken = ?").get(refreshToken);
353
+ if (row) {
354
+ return { sessionId: row.sessionId, userId: row.userId, newRefreshToken: refreshToken };
355
+ }
356
+ // Check previous refresh token (grace window)
357
+ row = db.query("SELECT sessionId, userId, refreshToken, prevTokenExpiresAt FROM sessions WHERE prevRefreshToken = ?").get(refreshToken);
358
+ if (!row)
359
+ return null;
360
+ const prevExpiry = row.prevTokenExpiresAt;
361
+ if (prevExpiry && prevExpiry > Date.now()) {
362
+ // Within grace window — return current refresh token
363
+ return { sessionId: row.sessionId, userId: row.userId, newRefreshToken: row.refreshToken };
364
+ }
365
+ // Grace window expired — theft detected, invalidate session
366
+ sqliteDeleteSession(row.sessionId);
367
+ return null;
368
+ };
369
+ export const sqliteRotateRefreshToken = (sessionId, newRefreshToken, newAccessToken) => {
370
+ const graceSeconds = getRotationGraceSeconds();
371
+ const prevTokenExpiresAt = Date.now() + graceSeconds * 1000;
372
+ getDb().run("UPDATE sessions SET prevRefreshToken = refreshToken, prevTokenExpiresAt = ?, refreshToken = ?, token = ? WHERE sessionId = ?", [prevTokenExpiresAt, newRefreshToken, newAccessToken, sessionId]);
373
+ };
239
374
  // ---------------------------------------------------------------------------
240
375
  // OAuth state helpers (used by src/lib/oauth.ts)
241
376
  // ---------------------------------------------------------------------------
package/dist/app.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { MiddlewareHandler } from "hono";
3
3
  import type { AppEnv } from "./lib/context";
4
- import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig } from "./lib/appConfig";
4
+ import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig, MfaConfig, MfaEmailOtpConfig } from "./lib/appConfig";
5
5
  import type { AuthAdapter } from "./lib/authAdapter";
6
6
  import type { OAuthProviderConfig } from "./lib/oauth";
7
7
  type StoreType = "redis" | "mongo" | "sqlite" | "memory";
@@ -92,6 +92,11 @@ export interface AuthRateLimitConfig {
92
92
  windowMs?: number;
93
93
  max?: number;
94
94
  };
95
+ /** Max account deletion attempts per user per window. Default: 3 per hour. */
96
+ deleteAccount?: {
97
+ windowMs?: number;
98
+ max?: number;
99
+ };
95
100
  /**
96
101
  * Store backend for auth rate limit counters.
97
102
  * Defaults to "redis" when Redis is enabled, otherwise "memory".
@@ -136,6 +141,32 @@ export interface AuthConfig {
136
141
  rateLimit?: AuthRateLimitConfig;
137
142
  /** Session concurrency and metadata persistence policy. */
138
143
  sessionPolicy?: AuthSessionPolicyConfig;
144
+ /** Account deletion configuration. Enables DELETE /auth/me when the adapter supports deleteUser. */
145
+ accountDeletion?: AccountDeletionConfig;
146
+ /**
147
+ * Refresh token configuration. When set, login/register return short-lived access tokens
148
+ * (default 15 min) alongside long-lived refresh tokens (default 30 days). Mounts POST /auth/refresh.
149
+ * When not configured, the existing 7-day JWT behavior is unchanged.
150
+ */
151
+ refreshTokens?: RefreshTokenConfig;
152
+ /**
153
+ * MFA/TOTP configuration. When set, enables MFA setup/verify/disable routes under /auth/mfa/*.
154
+ * Login returns { mfaRequired: true, mfaToken } when MFA is enabled for the user.
155
+ * OAuth logins skip MFA (the OAuth provider is treated as the second factor).
156
+ */
157
+ mfa?: MfaConfig;
158
+ }
159
+ export interface AccountDeletionConfig {
160
+ /** Called before deletion. Throw to abort (e.g., active subscription check). */
161
+ onBeforeDelete?: (userId: string) => Promise<void>;
162
+ /** Called after auth data is deleted. Runs at execution time — query current state, not a snapshot. */
163
+ onAfterDelete?: (userId: string) => Promise<void>;
164
+ /** When true, deletion is queued as a BullMQ job instead of running synchronously. Requires Redis + BullMQ. */
165
+ queued?: boolean;
166
+ /** Grace period in seconds before queued deletion executes. Default: 0 (immediate). */
167
+ gracePeriod?: number;
168
+ /** Called when deletion is scheduled (queued + gracePeriod > 0). Use to send a confirmation/cancel email. */
169
+ onDeletionScheduled?: (userId: string, email: string, cancelToken: string) => Promise<void>;
139
170
  }
140
171
  export interface AuthSessionPolicyConfig {
141
172
  /** Max simultaneous active sessions per user. Oldest is evicted when exceeded. Default: 6. */
@@ -156,7 +187,7 @@ export interface AuthSessionPolicyConfig {
156
187
  */
157
188
  trackLastActive?: boolean;
158
189
  }
159
- export type { PrimaryField, EmailVerificationConfig, PasswordResetConfig };
190
+ export type { PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig, MfaConfig, MfaEmailOtpConfig };
160
191
  export interface BotProtectionConfig {
161
192
  /**
162
193
  * List of IPv4 CIDRs (e.g. "198.51.100.0/24"), IPv4 addresses, or IPv6 addresses to block outright.
@@ -217,6 +248,46 @@ export interface ModelSchemasConfig {
217
248
  */
218
249
  registration?: "auto" | "explicit";
219
250
  }
251
+ export interface JobsConfig {
252
+ /** Enable the job status endpoint. Default: false. */
253
+ statusEndpoint?: boolean;
254
+ /**
255
+ * Auth protection for job endpoints.
256
+ * - `"userAuth"` — requires authenticated user session (cookie/token).
257
+ * - `"none"` — no auth (not recommended for production).
258
+ * - `MiddlewareHandler[]` — custom middleware stack (e.g., `[userAuth, requireRole("admin")]`).
259
+ *
260
+ * Default: `"none"`. You must explicitly configure auth.
261
+ */
262
+ auth?: "userAuth" | "none" | import("hono").MiddlewareHandler<AppEnv>[];
263
+ /** Required roles for accessing job endpoints. Only works when auth includes userAuth. */
264
+ roles?: string[];
265
+ /** Whitelist of queue names exposed. Default: [] (nothing exposed). */
266
+ allowedQueues?: string[];
267
+ /** When using userAuth, restrict job visibility to the user who created it. Default: false. */
268
+ scopeToUser?: boolean;
269
+ }
270
+ export interface TenantConfig {
271
+ [key: string]: unknown;
272
+ }
273
+ export interface TenancyConfig {
274
+ /** How tenant is identified. */
275
+ resolution: "header" | "subdomain" | "path";
276
+ /** Header name when resolution is "header". Default: "x-tenant-id". */
277
+ headerName?: string;
278
+ /** Path segment index when resolution is "path". Default: 0. */
279
+ pathSegment?: number;
280
+ /** Callback to validate/load tenant. Return null to reject. */
281
+ onResolve?: (tenantId: string) => Promise<TenantConfig | null>;
282
+ /** TTL in ms for caching onResolve results (LRU cache). Default: 60_000. Set 0 to disable. */
283
+ cacheTtlMs?: number;
284
+ /** Max entries in tenant resolution cache. Default: 500. */
285
+ cacheMaxSize?: number;
286
+ /** Paths that skip tenant resolution. Uses startsWith matching. Default: ["/health", "/docs", "/openapi.json"]. */
287
+ exemptPaths?: string[];
288
+ /** HTTP status when onResolve returns null. Default: 403. */
289
+ rejectionStatus?: 403 | 404;
290
+ }
220
291
  export interface CreateAppConfig {
221
292
  /** Absolute path to the service's routes directory (use import.meta.dir + "/routes") */
222
293
  routesDir: string;
@@ -237,5 +308,9 @@ export interface CreateAppConfig {
237
308
  middleware?: MiddlewareHandler<AppEnv>[];
238
309
  /** Database connection and store routing configuration */
239
310
  db?: DbConfig;
311
+ /** Job status endpoint configuration. Requires BullMQ + Redis. */
312
+ jobs?: JobsConfig;
313
+ /** Multi-tenancy configuration. When set, tenant middleware resolves tenant on each request. */
314
+ tenancy?: TenancyConfig;
240
315
  }
241
316
  export declare const createApp: (config: CreateAppConfig) => Promise<OpenAPIHono<AppEnv>>;
package/dist/app.js CHANGED
@@ -7,8 +7,8 @@ import { HttpError } from "./lib/HttpError";
7
7
  import { rateLimit } from "./middleware/rateLimit";
8
8
  import { bearerAuth } from "./middleware/bearerAuth";
9
9
  import { identify } from "./middleware/identify";
10
- import { HEADER_USER_TOKEN } from "./lib/constants";
11
- import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setPasswordResetConfig, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive } from "./lib/appConfig";
10
+ import { HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN } from "./lib/constants";
11
+ import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setPasswordResetConfig, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive, setRefreshTokenConfig, setMfaConfig } from "./lib/appConfig";
12
12
  import { setEmailVerificationStore } from "./lib/emailVerification";
13
13
  import { setPasswordResetStore } from "./lib/resetPassword";
14
14
  import { setAuthRateLimitStore } from "./lib/authRateLimit";
@@ -110,6 +110,8 @@ export const createApp = async (config) => {
110
110
  setPersistSessionMetadata(sessionPolicy.persistSessionMetadata ?? true);
111
111
  setIncludeInactiveSessions(sessionPolicy.includeInactiveSessions ?? false);
112
112
  setTrackLastActive(sessionPolicy.trackLastActive ?? false);
113
+ setRefreshTokenConfig(authConfig.refreshTokens ?? null);
114
+ setMfaConfig(authConfig.mfa ?? null);
113
115
  if (oauthProviders)
114
116
  initOAuthProviders(oauthProviders);
115
117
  const configuredOAuth = getConfiguredOAuthProviders();
@@ -125,7 +127,7 @@ export const createApp = async (config) => {
125
127
  const app = new OpenAPIHono();
126
128
  app.use(logger());
127
129
  app.use(secureHeaders());
128
- app.use(cors({ origin: corsOrigins, allowHeaders: ["Content-Type", "Authorization", HEADER_USER_TOKEN], exposeHeaders: ["x-cache"], credentials: true }));
130
+ app.use(cors({ origin: corsOrigins, allowHeaders: ["Content-Type", "Authorization", HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN], exposeHeaders: ["x-cache"], credentials: true }));
129
131
  if ((botCfg.blockList?.length ?? 0) > 0) {
130
132
  const { botProtection } = await import("./middleware/botProtection");
131
133
  app.use(botProtection({ blockList: botCfg.blockList }));
@@ -141,6 +143,11 @@ export const createApp = async (config) => {
141
143
  });
142
144
  }
143
145
  app.use(identify);
146
+ // Tenant resolution middleware (after identify, before user middleware + routes)
147
+ if (config.tenancy) {
148
+ const { createTenantMiddleware } = await import("./middleware/tenant");
149
+ app.use(createTenantMiddleware(config.tenancy));
150
+ }
144
151
  for (const mw of middleware)
145
152
  app.use(mw);
146
153
  setAppName(appName);
@@ -188,17 +195,35 @@ export const createApp = async (config) => {
188
195
  continue; // mounted separately below via createAuthRouter
189
196
  if (file === "oauth.ts")
190
197
  continue; // mounted separately below
198
+ if (file === "mfa.ts")
199
+ continue; // mounted separately below when mfa is configured
200
+ if (file === "jobs.ts")
201
+ continue; // mounted separately below when jobs.statusEndpoint is true
191
202
  const mod = await import(`${coreRoutesDir}/${file}`);
192
203
  if (mod.router)
193
204
  app.route("/", mod.router);
194
205
  }
195
206
  if (enableAuthRoutes) {
196
207
  const { createAuthRouter } = await import(`${coreRoutesDir}/auth`);
197
- app.route("/", createAuthRouter({ primaryField, emailVerification, passwordReset, rateLimit: authRateLimit }));
208
+ app.route("/", createAuthRouter({ primaryField, emailVerification, passwordReset, rateLimit: authRateLimit, accountDeletion: authConfig.accountDeletion, refreshTokens: authConfig.refreshTokens }));
198
209
  }
199
210
  if (configuredOAuth.length > 0) {
200
211
  app.route("/", createOAuthRouter(configuredOAuth, postOAuthRedirect));
201
212
  }
213
+ if (authConfig.mfa && enableAuthRoutes) {
214
+ const { setMfaChallengeStore, setMfaChallengeSqliteDb } = await import("./lib/mfaChallenge");
215
+ setMfaChallengeStore(sessions);
216
+ if (sessions === "sqlite") {
217
+ const { getDb } = await import("./adapters/sqliteAuth");
218
+ setMfaChallengeSqliteDb(getDb());
219
+ }
220
+ const { createMfaRouter } = await import(`${coreRoutesDir}/mfa`);
221
+ app.route("/", createMfaRouter());
222
+ }
223
+ if (config.jobs?.statusEndpoint) {
224
+ const { createJobsRouter } = await import(`${coreRoutesDir}/jobs`);
225
+ app.route("/", createJobsRouter(config.jobs));
226
+ }
202
227
  // Service routes — collect all, sort by optional exported `priority`, then mount
203
228
  const serviceGlob = new Bun.Glob("**/*.ts");
204
229
  const serviceFiles = [];
@@ -1,2 +1,2 @@
1
- export { createQueue, createWorker } from "../lib/queue";
2
- export type { Job } from "../lib/queue";
1
+ export { createQueue, createWorker, createCronWorker, cleanupStaleSchedulers, getRegisteredCronNames, createDLQHandler } from "../lib/queue";
2
+ export type { Job, CronSchedule, DLQOptions } from "../lib/queue";