@lastshotlabs/bunshot 0.0.13 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +2816 -1747
  2. package/dist/adapters/memoryAuth.d.ts +7 -0
  3. package/dist/adapters/memoryAuth.js +177 -2
  4. package/dist/adapters/mongoAuth.js +94 -0
  5. package/dist/adapters/sqliteAuth.d.ts +9 -0
  6. package/dist/adapters/sqliteAuth.js +190 -2
  7. package/dist/app.d.ts +120 -2
  8. package/dist/app.js +104 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +24 -8
  12. package/dist/index.js +15 -5
  13. package/dist/lib/appConfig.d.ts +81 -0
  14. package/dist/lib/appConfig.js +30 -0
  15. package/dist/lib/authAdapter.d.ts +54 -0
  16. package/dist/lib/authRateLimit.d.ts +2 -0
  17. package/dist/lib/authRateLimit.js +4 -0
  18. package/dist/lib/clientIp.d.ts +14 -0
  19. package/dist/lib/clientIp.js +52 -0
  20. package/dist/lib/constants.d.ts +4 -0
  21. package/dist/lib/constants.js +4 -0
  22. package/dist/lib/context.d.ts +2 -0
  23. package/dist/lib/createDtoMapper.d.ts +33 -0
  24. package/dist/lib/createDtoMapper.js +69 -0
  25. package/dist/lib/crypto.d.ts +11 -0
  26. package/dist/lib/crypto.js +22 -0
  27. package/dist/lib/emailVerification.d.ts +4 -0
  28. package/dist/lib/emailVerification.js +20 -12
  29. package/dist/lib/jwt.d.ts +1 -1
  30. package/dist/lib/jwt.js +19 -6
  31. package/dist/lib/mfaChallenge.d.ts +42 -0
  32. package/dist/lib/mfaChallenge.js +293 -0
  33. package/dist/lib/oauth.d.ts +14 -1
  34. package/dist/lib/oauth.js +19 -1
  35. package/dist/lib/oauthCode.d.ts +15 -0
  36. package/dist/lib/oauthCode.js +90 -0
  37. package/dist/lib/queue.d.ts +33 -0
  38. package/dist/lib/queue.js +98 -0
  39. package/dist/lib/resetPassword.js +12 -16
  40. package/dist/lib/roles.d.ts +4 -0
  41. package/dist/lib/roles.js +27 -0
  42. package/dist/lib/session.d.ts +12 -0
  43. package/dist/lib/session.js +165 -5
  44. package/dist/lib/tenant.d.ts +15 -0
  45. package/dist/lib/tenant.js +65 -0
  46. package/dist/lib/ws.js +5 -1
  47. package/dist/lib/zodToMongoose.d.ts +38 -0
  48. package/dist/lib/zodToMongoose.js +84 -0
  49. package/dist/middleware/bearerAuth.js +4 -3
  50. package/dist/middleware/botProtection.js +2 -2
  51. package/dist/middleware/cacheResponse.d.ts +1 -0
  52. package/dist/middleware/cacheResponse.js +18 -3
  53. package/dist/middleware/cors.d.ts +2 -0
  54. package/dist/middleware/cors.js +22 -8
  55. package/dist/middleware/csrf.d.ts +18 -0
  56. package/dist/middleware/csrf.js +115 -0
  57. package/dist/middleware/rateLimit.d.ts +2 -1
  58. package/dist/middleware/rateLimit.js +7 -5
  59. package/dist/middleware/requireRole.d.ts +14 -3
  60. package/dist/middleware/requireRole.js +46 -6
  61. package/dist/middleware/tenant.d.ts +5 -0
  62. package/dist/middleware/tenant.js +116 -0
  63. package/dist/models/AuthUser.d.ts +17 -0
  64. package/dist/models/AuthUser.js +17 -0
  65. package/dist/models/TenantRole.d.ts +15 -0
  66. package/dist/models/TenantRole.js +23 -0
  67. package/dist/routes/auth.d.ts +5 -3
  68. package/dist/routes/auth.js +173 -30
  69. package/dist/routes/jobs.d.ts +2 -0
  70. package/dist/routes/jobs.js +270 -0
  71. package/dist/routes/mfa.d.ts +5 -0
  72. package/dist/routes/mfa.js +616 -0
  73. package/dist/routes/oauth.js +378 -23
  74. package/dist/schemas/auth.d.ts +2 -0
  75. package/dist/schemas/auth.js +22 -1
  76. package/dist/server.d.ts +6 -0
  77. package/dist/server.js +19 -3
  78. package/dist/services/auth.d.ts +18 -5
  79. package/dist/services/auth.js +112 -18
  80. package/dist/services/mfa.d.ts +84 -0
  81. package/dist/services/mfa.js +543 -0
  82. package/dist/ws/index.js +3 -2
  83. package/docs/sections/adding-middleware/full.md +35 -0
  84. package/docs/sections/adding-models/full.md +125 -0
  85. package/docs/sections/adding-models/overview.md +13 -0
  86. package/docs/sections/adding-routes/full.md +182 -0
  87. package/docs/sections/adding-routes/overview.md +23 -0
  88. package/docs/sections/auth-flow/full.md +634 -0
  89. package/docs/sections/auth-flow/overview.md +10 -0
  90. package/docs/sections/cli/full.md +30 -0
  91. package/docs/sections/configuration/full.md +155 -0
  92. package/docs/sections/configuration/overview.md +17 -0
  93. package/docs/sections/configuration-example/full.md +117 -0
  94. package/docs/sections/configuration-example/overview.md +30 -0
  95. package/docs/sections/documentation/full.md +171 -0
  96. package/docs/sections/environment-variables/full.md +55 -0
  97. package/docs/sections/exports/full.md +92 -0
  98. package/docs/sections/extending-context/full.md +59 -0
  99. package/docs/sections/header.md +3 -0
  100. package/docs/sections/installation/full.md +6 -0
  101. package/docs/sections/jobs/full.md +140 -0
  102. package/docs/sections/jobs/overview.md +15 -0
  103. package/docs/sections/mongodb-connections/full.md +45 -0
  104. package/docs/sections/mongodb-connections/overview.md +7 -0
  105. package/docs/sections/multi-tenancy/full.md +66 -0
  106. package/docs/sections/multi-tenancy/overview.md +15 -0
  107. package/docs/sections/oauth/full.md +189 -0
  108. package/docs/sections/oauth/overview.md +16 -0
  109. package/docs/sections/package-development/full.md +7 -0
  110. package/docs/sections/peer-dependencies/full.md +47 -0
  111. package/docs/sections/quick-start/full.md +43 -0
  112. package/docs/sections/response-caching/full.md +117 -0
  113. package/docs/sections/response-caching/overview.md +13 -0
  114. package/docs/sections/roles/full.md +136 -0
  115. package/docs/sections/roles/overview.md +12 -0
  116. package/docs/sections/running-without-redis/full.md +16 -0
  117. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  118. package/docs/sections/stack/full.md +10 -0
  119. package/docs/sections/websocket/full.md +101 -0
  120. package/docs/sections/websocket/overview.md +5 -0
  121. package/docs/sections/websocket-rooms/full.md +97 -0
  122. package/docs/sections/websocket-rooms/overview.md +5 -0
  123. package/package.json +30 -9
package/dist/lib/roles.js CHANGED
@@ -20,3 +20,30 @@ export const removeUserRole = async (userId, role) => {
20
20
  requireMethod("removeRole");
21
21
  await adapter.removeRole(userId, role);
22
22
  };
23
+ // ---------------------------------------------------------------------------
24
+ // Tenant-scoped role helpers
25
+ // ---------------------------------------------------------------------------
26
+ export const getTenantRoles = async (userId, tenantId) => {
27
+ const adapter = getAuthAdapter();
28
+ if (!adapter.getTenantRoles)
29
+ requireMethod("getTenantRoles");
30
+ return adapter.getTenantRoles(userId, tenantId);
31
+ };
32
+ export const setTenantRoles = async (userId, tenantId, roles) => {
33
+ const adapter = getAuthAdapter();
34
+ if (!adapter.setTenantRoles)
35
+ requireMethod("setTenantRoles");
36
+ await adapter.setTenantRoles(userId, tenantId, roles);
37
+ };
38
+ export const addTenantRole = async (userId, tenantId, role) => {
39
+ const adapter = getAuthAdapter();
40
+ if (!adapter.addTenantRole)
41
+ requireMethod("addTenantRole");
42
+ await adapter.addTenantRole(userId, tenantId, role);
43
+ };
44
+ export const removeTenantRole = async (userId, tenantId, role) => {
45
+ const adapter = getAuthAdapter();
46
+ if (!adapter.removeTenantRole)
47
+ requireMethod("removeTenantRole");
48
+ await adapter.removeTenantRole(userId, tenantId, role);
49
+ };
@@ -11,6 +11,11 @@ export interface SessionInfo {
11
11
  userAgent?: string;
12
12
  isActive: boolean;
13
13
  }
14
+ export interface RefreshResult {
15
+ sessionId: string;
16
+ userId: string;
17
+ newRefreshToken: string;
18
+ }
14
19
  type SessionStore = "redis" | "mongo" | "sqlite" | "memory";
15
20
  export declare const setSessionStore: (store: SessionStore) => void;
16
21
  export declare const createSession: (userId: string, token: string, sessionId: string, metadata?: SessionMetadata) => Promise<void>;
@@ -19,5 +24,12 @@ export declare const deleteSession: (sessionId: string) => Promise<void>;
19
24
  export declare const getUserSessions: (userId: string) => Promise<SessionInfo[]>;
20
25
  export declare const getActiveSessionCount: (userId: string) => Promise<number>;
21
26
  export declare const evictOldestSession: (userId: string) => Promise<void>;
27
+ export declare const deleteUserSessions: (userId: string) => Promise<void>;
22
28
  export declare const updateSessionLastActive: (sessionId: string) => Promise<void>;
29
+ /** Store a refresh token on an existing session (called after session creation). */
30
+ export declare const setRefreshToken: (sessionId: string, refreshToken: string) => Promise<void>;
31
+ /** Look up a session by refresh token. Handles grace window and theft detection. */
32
+ export declare const getSessionByRefreshToken: (refreshToken: string) => Promise<RefreshResult | null>;
33
+ /** Rotate the refresh token: move current to prev with grace window, set new token + access token. */
34
+ export declare const rotateRefreshToken: (sessionId: string, newRefreshToken: string, newAccessToken: string) => Promise<void>;
23
35
  export {};
@@ -1,8 +1,8 @@
1
1
  import { getRedis } from "./redis";
2
2
  import { appConnection, mongoose } from "./mongo";
3
- import { getAppName, getPersistSessionMetadata, getIncludeInactiveSessions } from "./appConfig";
4
- import { sqliteCreateSession, sqliteGetSession, sqliteDeleteSession, sqliteGetUserSessions, sqliteGetActiveSessionCount, sqliteEvictOldestSession, sqliteUpdateSessionLastActive, } from "../adapters/sqliteAuth";
5
- import { memoryCreateSession, memoryGetSession, memoryDeleteSession, memoryGetUserSessions, memoryGetActiveSessionCount, memoryEvictOldestSession, memoryUpdateSessionLastActive, } from "../adapters/memoryAuth";
3
+ import { getAppName, getPersistSessionMetadata, getIncludeInactiveSessions, getRotationGraceSeconds, getRefreshTokenExpiry } from "./appConfig";
4
+ import { sqliteCreateSession, sqliteGetSession, sqliteDeleteSession, sqliteGetUserSessions, sqliteGetActiveSessionCount, sqliteEvictOldestSession, sqliteUpdateSessionLastActive, sqliteSetRefreshToken, sqliteGetSessionByRefreshToken, sqliteRotateRefreshToken, } from "../adapters/sqliteAuth";
5
+ import { memoryCreateSession, memoryGetSession, memoryDeleteSession, memoryGetUserSessions, memoryGetActiveSessionCount, memoryEvictOldestSession, memoryUpdateSessionLastActive, memorySetRefreshToken, memoryGetSessionByRefreshToken, memoryRotateRefreshToken, } from "../adapters/memoryAuth";
6
6
  function getSessionModel() {
7
7
  if (appConnection.models["Session"])
8
8
  return appConnection.models["Session"];
@@ -16,7 +16,11 @@ function getSessionModel() {
16
16
  expiresAt: { type: Date, required: true },
17
17
  ipAddress: { type: String },
18
18
  userAgent: { type: String },
19
+ refreshToken: { type: String, default: null },
20
+ prevRefreshToken: { type: String, default: null },
21
+ prevTokenExpiresAt: { type: Date, default: null },
19
22
  }, { collection: "sessions", timestamps: false });
23
+ sessionSchema.index({ refreshToken: 1 }, { unique: true, partialFilterExpression: { refreshToken: { $type: "string" } } });
20
24
  // Add TTL index only when metadata is not persisted — docs auto-delete at expiresAt.
21
25
  // When persisting, token is nulled (soft-delete) but the row is kept indefinitely.
22
26
  if (!getPersistSessionMetadata()) {
@@ -40,6 +44,9 @@ function redisSessionKey(sessionId) {
40
44
  function redisUserSessionsKey(userId) {
41
45
  return `usersessions:${getAppName()}:${userId}`;
42
46
  }
47
+ function redisRefreshTokenKey(refreshToken) {
48
+ return `refreshtoken:${getAppName()}:${refreshToken}`;
49
+ }
43
50
  async function redisCreateSession(userId, token, sessionId, metadata) {
44
51
  const now = Date.now();
45
52
  const expiresAt = now + TTL_MS;
@@ -78,8 +85,13 @@ async function redisDeleteSession(sessionId) {
78
85
  return;
79
86
  const rec = JSON.parse(raw);
80
87
  const persist = getPersistSessionMetadata();
88
+ // Clean up refresh token reverse-lookup keys
89
+ if (rec.refreshToken)
90
+ await redis.del(redisRefreshTokenKey(rec.refreshToken));
91
+ if (rec.prevRefreshToken)
92
+ await redis.del(redisRefreshTokenKey(rec.prevRefreshToken));
81
93
  if (persist) {
82
- const updated = { ...JSON.parse(raw), token: null };
94
+ const updated = { ...rec, token: null, refreshToken: null, prevRefreshToken: null, prevTokenExpiresAt: null };
83
95
  await redis.set(redisSessionKey(sessionId), JSON.stringify(updated));
84
96
  }
85
97
  else {
@@ -169,6 +181,66 @@ async function redisUpdateSessionLastActive(sessionId) {
169
181
  await redis.set(redisSessionKey(sessionId), JSON.stringify(rec), "EX", ttlRemaining);
170
182
  }
171
183
  }
184
+ async function redisSetRefreshToken(sessionId, refreshToken) {
185
+ const redis = getRedis();
186
+ const raw = await redis.get(redisSessionKey(sessionId));
187
+ if (!raw)
188
+ return;
189
+ const rec = JSON.parse(raw);
190
+ rec.refreshToken = refreshToken;
191
+ const refreshExpiry = getRefreshTokenExpiry();
192
+ await redis.set(redisSessionKey(sessionId), JSON.stringify(rec));
193
+ await redis.set(redisRefreshTokenKey(refreshToken), sessionId, "EX", refreshExpiry);
194
+ }
195
+ async function redisGetSessionByRefreshToken(refreshToken) {
196
+ const redis = getRedis();
197
+ const sessionId = await redis.get(redisRefreshTokenKey(refreshToken));
198
+ if (!sessionId)
199
+ return null;
200
+ const raw = await redis.get(redisSessionKey(sessionId));
201
+ if (!raw)
202
+ return null;
203
+ const rec = JSON.parse(raw);
204
+ // Current refresh token matches
205
+ if (rec.refreshToken === refreshToken) {
206
+ return { sessionId: rec.sessionId, userId: rec.userId, newRefreshToken: refreshToken };
207
+ }
208
+ // Check grace window: old token used within grace period
209
+ if (rec.prevRefreshToken === refreshToken && rec.prevTokenExpiresAt && rec.prevTokenExpiresAt > Date.now()) {
210
+ // Return current refresh token — client missed the rotation response
211
+ return { sessionId: rec.sessionId, userId: rec.userId, newRefreshToken: rec.refreshToken };
212
+ }
213
+ // Old token used after grace window — token family theft detected, invalidate session
214
+ if (rec.prevRefreshToken === refreshToken) {
215
+ await redisDeleteSession(sessionId);
216
+ return null;
217
+ }
218
+ return null;
219
+ }
220
+ async function redisRotateRefreshToken(sessionId, newRefreshToken, newAccessToken) {
221
+ const redis = getRedis();
222
+ const raw = await redis.get(redisSessionKey(sessionId));
223
+ if (!raw)
224
+ return;
225
+ const rec = JSON.parse(raw);
226
+ const graceSeconds = getRotationGraceSeconds();
227
+ const refreshExpiry = getRefreshTokenExpiry();
228
+ // Move current to prev with grace window
229
+ const oldRefreshToken = rec.refreshToken;
230
+ rec.prevRefreshToken = oldRefreshToken;
231
+ rec.prevTokenExpiresAt = Date.now() + graceSeconds * 1000;
232
+ rec.refreshToken = newRefreshToken;
233
+ rec.token = newAccessToken;
234
+ await redis.set(redisSessionKey(sessionId), JSON.stringify(rec));
235
+ // Set new reverse-lookup with full refresh expiry
236
+ await redis.set(redisRefreshTokenKey(newRefreshToken), sessionId, "EX", refreshExpiry);
237
+ // Update old reverse-lookup to expire after grace window.
238
+ // Use a minimum TTL of 60s so that theft detection still works when grace is 0.
239
+ if (oldRefreshToken) {
240
+ const oldKeyTtl = Math.max(graceSeconds, 60);
241
+ await redis.expire(redisRefreshTokenKey(oldRefreshToken), oldKeyTtl);
242
+ }
243
+ }
172
244
  // ---------------------------------------------------------------------------
173
245
  // Mongo helpers
174
246
  // ---------------------------------------------------------------------------
@@ -201,6 +273,45 @@ async function mongoGetUserSessions(userId) {
201
273
  }
202
274
  return results;
203
275
  }
276
+ async function mongoSetRefreshToken(sessionId, refreshToken) {
277
+ await getSessionModel().updateOne({ sessionId }, { $set: { refreshToken } });
278
+ }
279
+ async function mongoGetSessionByRefreshToken(refreshToken) {
280
+ const Session = getSessionModel();
281
+ // Check current refresh token
282
+ let doc = await Session.findOne({ refreshToken }).lean();
283
+ if (doc) {
284
+ return { sessionId: doc.sessionId, userId: doc.userId, newRefreshToken: refreshToken };
285
+ }
286
+ // Check previous refresh token (grace window)
287
+ doc = await Session.findOne({ prevRefreshToken: refreshToken }).lean();
288
+ if (!doc)
289
+ return null;
290
+ if (doc.prevTokenExpiresAt && doc.prevTokenExpiresAt > new Date()) {
291
+ // Within grace window — return current refresh token
292
+ return { sessionId: doc.sessionId, userId: doc.userId, newRefreshToken: doc.refreshToken };
293
+ }
294
+ // Grace window expired — token family theft detected, invalidate session
295
+ if (getPersistSessionMetadata()) {
296
+ await Session.updateOne({ sessionId: doc.sessionId }, { $set: { token: null, refreshToken: null, prevRefreshToken: null, prevTokenExpiresAt: null } });
297
+ }
298
+ else {
299
+ await Session.deleteOne({ sessionId: doc.sessionId });
300
+ }
301
+ return null;
302
+ }
303
+ async function mongoRotateRefreshToken(sessionId, newRefreshToken, newAccessToken) {
304
+ const graceSeconds = getRotationGraceSeconds();
305
+ const Session = getSessionModel();
306
+ const doc = await Session.findOne({ sessionId });
307
+ if (!doc)
308
+ return;
309
+ doc.prevRefreshToken = doc.refreshToken;
310
+ doc.prevTokenExpiresAt = new Date(Date.now() + graceSeconds * 1000);
311
+ doc.refreshToken = newRefreshToken;
312
+ doc.token = newAccessToken;
313
+ await doc.save();
314
+ }
204
315
  // ---------------------------------------------------------------------------
205
316
  // Public API
206
317
  // ---------------------------------------------------------------------------
@@ -255,7 +366,7 @@ export const deleteSession = async (sessionId) => {
255
366
  }
256
367
  // mongo
257
368
  if (getPersistSessionMetadata()) {
258
- await getSessionModel().updateOne({ sessionId }, { $set: { token: null } });
369
+ await getSessionModel().updateOne({ sessionId }, { $set: { token: null, refreshToken: null, prevRefreshToken: null, prevTokenExpiresAt: null } });
259
370
  }
260
371
  else {
261
372
  await getSessionModel().deleteOne({ sessionId });
@@ -303,6 +414,10 @@ export const evictOldestSession = async (userId) => {
303
414
  if (oldest)
304
415
  await deleteSession(oldest.sessionId);
305
416
  };
417
+ export const deleteUserSessions = async (userId) => {
418
+ const sessions = await getUserSessions(userId);
419
+ await Promise.all(sessions.map((s) => deleteSession(s.sessionId)));
420
+ };
306
421
  export const updateSessionLastActive = async (sessionId) => {
307
422
  if (_store === "memory") {
308
423
  memoryUpdateSessionLastActive(sessionId);
@@ -319,3 +434,48 @@ export const updateSessionLastActive = async (sessionId) => {
319
434
  // mongo
320
435
  await getSessionModel().updateOne({ sessionId }, { $set: { lastActiveAt: new Date() } });
321
436
  };
437
+ // ---------------------------------------------------------------------------
438
+ // Refresh token API
439
+ // ---------------------------------------------------------------------------
440
+ /** Store a refresh token on an existing session (called after session creation). */
441
+ export const setRefreshToken = async (sessionId, refreshToken) => {
442
+ if (_store === "memory") {
443
+ memorySetRefreshToken(sessionId, refreshToken);
444
+ return;
445
+ }
446
+ if (_store === "sqlite") {
447
+ sqliteSetRefreshToken(sessionId, refreshToken);
448
+ return;
449
+ }
450
+ if (_store === "redis") {
451
+ await redisSetRefreshToken(sessionId, refreshToken);
452
+ return;
453
+ }
454
+ await mongoSetRefreshToken(sessionId, refreshToken);
455
+ };
456
+ /** Look up a session by refresh token. Handles grace window and theft detection. */
457
+ export const getSessionByRefreshToken = async (refreshToken) => {
458
+ if (_store === "memory")
459
+ return memoryGetSessionByRefreshToken(refreshToken);
460
+ if (_store === "sqlite")
461
+ return sqliteGetSessionByRefreshToken(refreshToken);
462
+ if (_store === "redis")
463
+ return redisGetSessionByRefreshToken(refreshToken);
464
+ return mongoGetSessionByRefreshToken(refreshToken);
465
+ };
466
+ /** Rotate the refresh token: move current to prev with grace window, set new token + access token. */
467
+ export const rotateRefreshToken = async (sessionId, newRefreshToken, newAccessToken) => {
468
+ if (_store === "memory") {
469
+ memoryRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
470
+ return;
471
+ }
472
+ if (_store === "sqlite") {
473
+ sqliteRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
474
+ return;
475
+ }
476
+ if (_store === "redis") {
477
+ await redisRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
478
+ return;
479
+ }
480
+ await mongoRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
481
+ };
@@ -0,0 +1,15 @@
1
+ export interface TenantInfo {
2
+ tenantId: string;
3
+ displayName?: string;
4
+ config?: Record<string, unknown>;
5
+ createdAt: Date;
6
+ deletedAt?: Date | null;
7
+ }
8
+ export interface CreateTenantOptions {
9
+ displayName?: string;
10
+ config?: Record<string, unknown>;
11
+ }
12
+ export declare const createTenant: (tenantId: string, options?: CreateTenantOptions) => Promise<void>;
13
+ export declare const deleteTenant: (tenantId: string) => Promise<void>;
14
+ export declare const getTenant: (tenantId: string) => Promise<TenantInfo | null>;
15
+ export declare const listTenants: () => Promise<TenantInfo[]>;
@@ -0,0 +1,65 @@
1
+ import { authConnection, mongoose } from "./mongo";
2
+ let _TenantModel = null;
3
+ function getTenantModel() {
4
+ if (!_TenantModel) {
5
+ const { Schema } = mongoose;
6
+ const schema = new Schema({
7
+ tenantId: { type: String, required: true, unique: true },
8
+ displayName: { type: String },
9
+ config: { type: Schema.Types.Mixed },
10
+ deletedAt: { type: Date, default: null },
11
+ }, { timestamps: true });
12
+ _TenantModel = authConnection.model("Tenant", schema);
13
+ }
14
+ return _TenantModel;
15
+ }
16
+ // Proxy for lazy model resolution (same pattern as AuthUser)
17
+ const Tenant = new Proxy({}, {
18
+ get(_, prop) {
19
+ const model = getTenantModel();
20
+ const val = model[prop];
21
+ return typeof val === "function" ? val.bind(model) : val;
22
+ },
23
+ });
24
+ export const createTenant = async (tenantId, options) => {
25
+ const existing = await Tenant.findOne({ tenantId }).lean();
26
+ if (existing && !existing.deletedAt) {
27
+ throw new Error(`Tenant "${tenantId}" already exists`);
28
+ }
29
+ if (existing && existing.deletedAt) {
30
+ // Reactivate soft-deleted tenant
31
+ await Tenant.findOneAndUpdate({ tenantId }, { deletedAt: null, displayName: options?.displayName, config: options?.config });
32
+ return;
33
+ }
34
+ await Tenant.create({
35
+ tenantId,
36
+ displayName: options?.displayName,
37
+ config: options?.config,
38
+ });
39
+ };
40
+ export const deleteTenant = async (tenantId) => {
41
+ const { invalidateTenantCache } = await import("../middleware/tenant");
42
+ // Soft-delete
43
+ await Tenant.findOneAndUpdate({ tenantId }, { deletedAt: new Date() });
44
+ invalidateTenantCache(tenantId);
45
+ };
46
+ export const getTenant = async (tenantId) => {
47
+ const doc = await Tenant.findOne({ tenantId, deletedAt: null }).lean();
48
+ if (!doc)
49
+ return null;
50
+ return {
51
+ tenantId: doc.tenantId,
52
+ displayName: doc.displayName,
53
+ config: doc.config,
54
+ createdAt: doc.createdAt,
55
+ };
56
+ };
57
+ export const listTenants = async () => {
58
+ const docs = await Tenant.find({ deletedAt: null }).lean();
59
+ return docs.map((doc) => ({
60
+ tenantId: doc.tenantId,
61
+ displayName: doc.displayName,
62
+ config: doc.config,
63
+ createdAt: doc.createdAt,
64
+ }));
65
+ };
package/dist/lib/ws.js CHANGED
@@ -11,9 +11,13 @@ const _roomRegistry = new Map();
11
11
  export const getRooms = () => [..._roomRegistry.keys()];
12
12
  /** Socket IDs subscribed to a given room */
13
13
  export const getRoomSubscribers = (room) => [...(_roomRegistry.get(room) ?? [])];
14
+ const MAX_ROOM_ACTION_SIZE = 4096; // 4 KB — room actions are small JSON payloads
14
15
  export const handleRoomActions = async (ws, message, onSubscribe) => {
15
16
  try {
16
- const data = JSON.parse(typeof message === "string" ? message : Buffer.from(message).toString());
17
+ const raw = typeof message === "string" ? message : Buffer.from(message).toString();
18
+ if (raw.length > MAX_ROOM_ACTION_SIZE)
19
+ return false; // not a room action
20
+ const data = JSON.parse(raw);
17
21
  if (data.action === "subscribe" && typeof data.room === "string") {
18
22
  if (onSubscribe && !(await onSubscribe(ws, data.room))) {
19
23
  ws.send(JSON.stringify({ event: "subscribe_denied", room: data.room }));
@@ -0,0 +1,38 @@
1
+ import { Schema } from "mongoose";
2
+ type ZodSchema = any;
3
+ export type ZodToMongooseRefConfig = {
4
+ /** DB field name (e.g., "account") */
5
+ dbField: string;
6
+ /** Referenced model name (e.g., "Account") */
7
+ ref: string;
8
+ };
9
+ export type ZodToMongooseConfig = {
10
+ /** DB-only fields not in the Zod schema (e.g., user ref) */
11
+ dbFields?: Record<string, unknown>;
12
+ /** API fields that map to ObjectId refs: { accountId: { dbField: "account", ref: "Account" } } */
13
+ refs?: Record<string, ZodToMongooseRefConfig>;
14
+ /** Override Mongoose type for specific fields (e.g., { date: { type: Date, required: true } }) */
15
+ typeOverrides?: Record<string, unknown>;
16
+ /** Subdocument array fields: { items: mongooseSubSchema } */
17
+ subdocSchemas?: Record<string, Schema>;
18
+ };
19
+ /**
20
+ * Derive a Mongoose SchemaDefinition from a Zod object schema.
21
+ *
22
+ * Business fields are auto-converted from Zod types to Mongoose types.
23
+ * DB-specific concerns (ObjectId refs, type overrides, subdocuments) are declared via config.
24
+ *
25
+ * The `id` field is automatically excluded (Mongoose provides `_id`).
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * const AccountMongoSchema = new Schema(
30
+ * zodToMongoose(AccountSchema, {
31
+ * dbFields: { user: { type: Schema.Types.ObjectId, ref: "UserProfile", required: true } },
32
+ * }),
33
+ * { timestamps: true }
34
+ * );
35
+ * ```
36
+ */
37
+ export declare function zodToMongoose(zodSchema: ZodSchema, config?: ZodToMongooseConfig): Record<string, unknown>;
38
+ export {};
@@ -0,0 +1,84 @@
1
+ import { Schema } from "mongoose";
2
+ /** Unwrap nullable, optional, and default wrappers to get the core Zod type */
3
+ function unwrap(zodType) {
4
+ let t = zodType;
5
+ let required = true;
6
+ while (true) {
7
+ const defType = t._zod?.def?.type;
8
+ if (defType === "nullable") {
9
+ t = t._zod.def.innerType;
10
+ required = false;
11
+ }
12
+ else if (defType === "optional") {
13
+ t = t._zod.def.innerType;
14
+ required = false;
15
+ }
16
+ else if (defType === "default") {
17
+ t = t._zod.def.innerType;
18
+ required = false;
19
+ }
20
+ else
21
+ break;
22
+ }
23
+ return { core: t, required };
24
+ }
25
+ /** Convert a single Zod type to a Mongoose field definition */
26
+ function toMongooseField(zodType) {
27
+ const { core, required } = unwrap(zodType);
28
+ const defType = core._zod?.def?.type;
29
+ if (defType === "string")
30
+ return { type: String, required };
31
+ if (defType === "number")
32
+ return { type: Number, required };
33
+ if (defType === "boolean")
34
+ return { type: Boolean, required };
35
+ if (defType === "date")
36
+ return { type: Date, required };
37
+ if (defType === "enum")
38
+ return { type: String, enum: core.options, required };
39
+ return { type: Schema.Types.Mixed, required };
40
+ }
41
+ /**
42
+ * Derive a Mongoose SchemaDefinition from a Zod object schema.
43
+ *
44
+ * Business fields are auto-converted from Zod types to Mongoose types.
45
+ * DB-specific concerns (ObjectId refs, type overrides, subdocuments) are declared via config.
46
+ *
47
+ * The `id` field is automatically excluded (Mongoose provides `_id`).
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * const AccountMongoSchema = new Schema(
52
+ * zodToMongoose(AccountSchema, {
53
+ * dbFields: { user: { type: Schema.Types.ObjectId, ref: "UserProfile", required: true } },
54
+ * }),
55
+ * { timestamps: true }
56
+ * );
57
+ * ```
58
+ */
59
+ export function zodToMongoose(zodSchema, config = {}) {
60
+ const shape = zodSchema.shape;
61
+ const fields = {};
62
+ for (const [apiField, zodType] of Object.entries(shape)) {
63
+ if (apiField === "id")
64
+ continue;
65
+ if (config.refs?.[apiField]) {
66
+ const { dbField, ref } = config.refs[apiField];
67
+ fields[dbField] = { type: Schema.Types.ObjectId, ref, required: true };
68
+ continue;
69
+ }
70
+ if (config.typeOverrides?.[apiField]) {
71
+ fields[apiField] = config.typeOverrides[apiField];
72
+ continue;
73
+ }
74
+ if (config.subdocSchemas?.[apiField]) {
75
+ fields[apiField] = [config.subdocSchemas[apiField]];
76
+ continue;
77
+ }
78
+ fields[apiField] = toMongooseField(zodType);
79
+ }
80
+ if (config.dbFields) {
81
+ Object.assign(fields, config.dbFields);
82
+ }
83
+ return fields;
84
+ }
@@ -1,9 +1,10 @@
1
- const isProd = process.env.NODE_ENV === "production";
2
- const validToken = isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV;
1
+ import { timingSafeEqual } from "../lib/crypto";
3
2
  export const bearerAuth = async (c, next) => {
3
+ const isProd = process.env.NODE_ENV === "production";
4
+ const validToken = isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV;
4
5
  const header = c.req.header("Authorization");
5
6
  const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
6
- if (!token || token !== validToken) {
7
+ if (!token || !validToken || !timingSafeEqual(token, validToken)) {
7
8
  return c.json({ error: "Unauthorized" }, 401);
8
9
  }
9
10
  await next();
@@ -1,3 +1,4 @@
1
+ import { getClientIp } from "../lib/clientIp";
1
2
  // ---------------------------------------------------------------------------
2
3
  // CIDR helpers (IPv4 only; IPv6 exact-match supported)
3
4
  // ---------------------------------------------------------------------------
@@ -40,8 +41,7 @@ export const botProtection = ({ blockList = [], }) => {
40
41
  if (blockList.length === 0)
41
42
  return (_c, next) => next();
42
43
  return async (c, next) => {
43
- const raw = c.req.header("x-forwarded-for") ?? "";
44
- const ip = raw.split(",")[0]?.trim() ?? "unknown";
44
+ const ip = getClientIp(c);
45
45
  if (ip !== "unknown" && isBlocked(ip, blockList)) {
46
46
  return c.json({ error: "Forbidden" }, 403);
47
47
  }
@@ -1,5 +1,6 @@
1
1
  import type { MiddlewareHandler } from "hono";
2
2
  import type { AppEnv } from "../lib/context";
3
+ export declare function getCacheModel(): import("mongoose").Model<any, {}, {}, {}, any, any, any>;
3
4
  type CacheStore = "redis" | "mongo" | "sqlite" | "memory";
4
5
  export declare const setCacheStore: (store: CacheStore) => void;
5
6
  export declare const bustCache: (key: string) => Promise<void>;
@@ -3,7 +3,7 @@ import { getAppName } from "../lib/appConfig";
3
3
  import { appConnection, mongoose } from "../lib/mongo";
4
4
  import { isSqliteReady, sqliteGetCache, sqliteSetCache, sqliteDelCache, sqliteDelCachePattern } from "../adapters/sqliteAuth";
5
5
  import { memoryGetCache, memorySetCache, memoryDelCache, memoryDelCachePattern } from "../adapters/memoryAuth";
6
- function getCacheModel() {
6
+ export function getCacheModel() {
7
7
  if (appConnection.models["CacheEntry"])
8
8
  return appConnection.models["CacheEntry"];
9
9
  const { Schema } = mongoose;
@@ -130,11 +130,22 @@ export const bustCachePattern = async (pattern) => {
130
130
  const fullPattern = `cache:${getAppName()}:${pattern}`;
131
131
  await Promise.all([storeDelPattern("redis", fullPattern), storeDelPattern("mongo", fullPattern), storeDelPattern("sqlite", fullPattern), storeDelPattern("memory", fullPattern)]);
132
132
  };
133
+ /** Headers that must never be cached — storing these can cause session fixation or auth bypass. */
134
+ const UNCACHEABLE_HEADERS = new Set([
135
+ "set-cookie",
136
+ "www-authenticate",
137
+ "authorization",
138
+ "x-csrf-token",
139
+ "proxy-authenticate",
140
+ ]);
133
141
  export const cacheResponse = ({ ttl, key, store = _defaultCacheStore }) => {
134
142
  return async (c, next) => {
135
143
  const appName = getAppName();
136
144
  const rawKey = typeof key === "function" ? key(c) : key;
137
- const cacheKey = `cache:${appName}:${rawKey}`;
145
+ // Per-tenant namespacing: prevents two tenants caching the same key from colliding
146
+ const tenantId = c.get("tenantId");
147
+ const tenantSegment = tenantId ? `${tenantId}:` : "";
148
+ const cacheKey = `cache:${appName}:${tenantSegment}${rawKey}`;
138
149
  const cached = await storeGet(store, cacheKey);
139
150
  if (cached) {
140
151
  const { status, headers, body } = JSON.parse(cached);
@@ -148,7 +159,11 @@ export const cacheResponse = ({ ttl, key, store = _defaultCacheStore }) => {
148
159
  if (res.status >= 200 && res.status < 300) {
149
160
  const body = await res.text();
150
161
  const headers = {};
151
- res.headers.forEach((value, name) => { headers[name] = value; });
162
+ res.headers.forEach((value, name) => {
163
+ if (!UNCACHEABLE_HEADERS.has(name.toLowerCase())) {
164
+ headers[name] = value;
165
+ }
166
+ });
152
167
  await storeSet(store, cacheKey, JSON.stringify({ status: res.status, headers, body }), ttl);
153
168
  c.res = new Response(body, {
154
169
  status: res.status,
@@ -1,2 +1,4 @@
1
1
  import type { Middleware } from ".";
2
+ /** Configure the allowed CORS origins. Call once at startup. */
3
+ export declare const setCorsOrigins: (origins: string | string[]) => void;
2
4
  export declare const cors: Middleware;
@@ -1,17 +1,31 @@
1
+ let _allowedOrigins = "*";
2
+ /** Configure the allowed CORS origins. Call once at startup. */
3
+ export const setCorsOrigins = (origins) => { _allowedOrigins = origins; };
1
4
  export const cors = async (req, next) => {
5
+ const origin = req.headers.get("Origin");
6
+ const headers = corsHeaders(origin);
2
7
  if (req.method === "OPTIONS") {
3
- return new Response(null, { status: 204, headers: corsHeaders() });
8
+ return new Response(null, { status: 204, headers });
4
9
  }
5
10
  const res = await next(req);
6
- const headers = new Headers(res.headers);
7
- for (const [k, v] of Object.entries(corsHeaders()))
8
- headers.set(k, v);
9
- return new Response(res.body, { status: res.status, headers });
11
+ const resHeaders = new Headers(res.headers);
12
+ for (const [k, v] of Object.entries(headers))
13
+ resHeaders.set(k, v);
14
+ return new Response(res.body, { status: res.status, headers: resHeaders });
10
15
  };
11
- const corsHeaders = () => {
16
+ function corsHeaders(requestOrigin) {
17
+ let allowOrigin;
18
+ if (_allowedOrigins === "*") {
19
+ allowOrigin = "*";
20
+ }
21
+ else {
22
+ const origins = Array.isArray(_allowedOrigins) ? _allowedOrigins : [_allowedOrigins];
23
+ allowOrigin = requestOrigin && origins.includes(requestOrigin) ? requestOrigin : origins[0];
24
+ }
12
25
  return {
13
- "Access-Control-Allow-Origin": "*",
26
+ "Access-Control-Allow-Origin": allowOrigin,
14
27
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
15
28
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
29
+ ...(allowOrigin !== "*" ? { Vary: "Origin" } : {}),
16
30
  };
17
- };
31
+ }
@@ -0,0 +1,18 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { setCookie, deleteCookie } from "hono/cookie";
3
+ import type { AppEnv } from "../lib/context";
4
+ export interface CsrfMiddlewareOptions {
5
+ exemptPaths?: string[];
6
+ checkOrigin?: boolean;
7
+ allowedOrigins?: string | string[];
8
+ }
9
+ /**
10
+ * Refreshes the CSRF token cookie — call on login/register to prevent
11
+ * session fixation-adjacent attacks.
12
+ */
13
+ export declare function refreshCsrfToken(c: Parameters<typeof setCookie>[0]): void;
14
+ /**
15
+ * Clears the CSRF token cookie — call on logout.
16
+ */
17
+ export declare function clearCsrfToken(c: Parameters<typeof deleteCookie>[0]): void;
18
+ export declare const csrfProtection: (options?: CsrfMiddlewareOptions) => MiddlewareHandler<AppEnv>;