@lastshotlabs/bunshot 0.0.10 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +2510 -1580
  2. package/dist/adapters/memoryAuth.d.ts +4 -0
  3. package/dist/adapters/memoryAuth.js +131 -2
  4. package/dist/adapters/mongoAuth.js +56 -0
  5. package/dist/adapters/sqliteAuth.d.ts +6 -0
  6. package/dist/adapters/sqliteAuth.js +137 -2
  7. package/dist/app.d.ts +107 -2
  8. package/dist/app.js +83 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +15 -5
  12. package/dist/index.js +10 -3
  13. package/dist/lib/appConfig.d.ts +46 -0
  14. package/dist/lib/appConfig.js +20 -0
  15. package/dist/lib/authAdapter.d.ts +30 -0
  16. package/dist/lib/constants.d.ts +2 -0
  17. package/dist/lib/constants.js +2 -0
  18. package/dist/lib/context.d.ts +2 -0
  19. package/dist/lib/createDtoMapper.d.ts +33 -0
  20. package/dist/lib/createDtoMapper.js +69 -0
  21. package/dist/lib/createRoute.d.ts +61 -0
  22. package/dist/lib/createRoute.js +147 -0
  23. package/dist/lib/jwt.d.ts +1 -1
  24. package/dist/lib/jwt.js +2 -2
  25. package/dist/lib/mfaChallenge.d.ts +20 -0
  26. package/dist/lib/mfaChallenge.js +184 -0
  27. package/dist/lib/queue.d.ts +33 -0
  28. package/dist/lib/queue.js +98 -0
  29. package/dist/lib/roles.d.ts +4 -0
  30. package/dist/lib/roles.js +27 -0
  31. package/dist/lib/session.d.ts +12 -0
  32. package/dist/lib/session.js +163 -5
  33. package/dist/lib/tenant.d.ts +15 -0
  34. package/dist/lib/tenant.js +65 -0
  35. package/dist/lib/zodToMongoose.d.ts +38 -0
  36. package/dist/lib/zodToMongoose.js +84 -0
  37. package/dist/middleware/cacheResponse.js +4 -1
  38. package/dist/middleware/rateLimit.d.ts +2 -1
  39. package/dist/middleware/rateLimit.js +5 -2
  40. package/dist/middleware/requireRole.d.ts +14 -3
  41. package/dist/middleware/requireRole.js +46 -6
  42. package/dist/middleware/tenant.d.ts +5 -0
  43. package/dist/middleware/tenant.js +116 -0
  44. package/dist/models/AuthUser.d.ts +8 -0
  45. package/dist/models/AuthUser.js +8 -0
  46. package/dist/models/TenantRole.d.ts +15 -0
  47. package/dist/models/TenantRole.js +23 -0
  48. package/dist/routes/auth.d.ts +5 -3
  49. package/dist/routes/auth.js +253 -80
  50. package/dist/routes/jobs.d.ts +2 -0
  51. package/dist/routes/jobs.js +270 -0
  52. package/dist/routes/mfa.d.ts +1 -0
  53. package/dist/routes/mfa.js +409 -0
  54. package/dist/routes/oauth.js +107 -16
  55. package/dist/server.js +9 -0
  56. package/dist/services/auth.d.ts +21 -2
  57. package/dist/services/auth.js +97 -17
  58. package/dist/services/mfa.d.ts +37 -0
  59. package/dist/services/mfa.js +276 -0
  60. package/docs/sections/adding-middleware/full.md +35 -0
  61. package/docs/sections/adding-models/full.md +125 -0
  62. package/docs/sections/adding-models/overview.md +13 -0
  63. package/docs/sections/adding-routes/full.md +182 -0
  64. package/docs/sections/adding-routes/overview.md +23 -0
  65. package/docs/sections/auth-flow/full.md +456 -0
  66. package/docs/sections/auth-flow/overview.md +10 -0
  67. package/docs/sections/cli/full.md +30 -0
  68. package/docs/sections/configuration/full.md +135 -0
  69. package/docs/sections/configuration/overview.md +17 -0
  70. package/docs/sections/configuration-example/full.md +99 -0
  71. package/docs/sections/configuration-example/overview.md +30 -0
  72. package/docs/sections/documentation/full.md +171 -0
  73. package/docs/sections/environment-variables/full.md +55 -0
  74. package/docs/sections/exports/full.md +83 -0
  75. package/docs/sections/extending-context/full.md +59 -0
  76. package/docs/sections/header.md +3 -0
  77. package/docs/sections/installation/full.md +6 -0
  78. package/docs/sections/jobs/full.md +140 -0
  79. package/docs/sections/jobs/overview.md +15 -0
  80. package/docs/sections/mongodb-connections/full.md +45 -0
  81. package/docs/sections/mongodb-connections/overview.md +7 -0
  82. package/docs/sections/multi-tenancy/full.md +62 -0
  83. package/docs/sections/multi-tenancy/overview.md +15 -0
  84. package/docs/sections/oauth/full.md +119 -0
  85. package/docs/sections/oauth/overview.md +16 -0
  86. package/docs/sections/package-development/full.md +7 -0
  87. package/docs/sections/peer-dependencies/full.md +43 -0
  88. package/docs/sections/quick-start/full.md +43 -0
  89. package/docs/sections/response-caching/full.md +115 -0
  90. package/docs/sections/response-caching/overview.md +13 -0
  91. package/docs/sections/roles/full.md +136 -0
  92. package/docs/sections/roles/overview.md +12 -0
  93. package/docs/sections/running-without-redis/full.md +16 -0
  94. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  95. package/docs/sections/stack/full.md +10 -0
  96. package/docs/sections/websocket/full.md +100 -0
  97. package/docs/sections/websocket/overview.md +5 -0
  98. package/docs/sections/websocket-rooms/full.md +97 -0
  99. package/docs/sections/websocket-rooms/overview.md +5 -0
  100. package/package.json +19 -10
@@ -1,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, sparse: true },
20
+ prevRefreshToken: { type: String, default: null },
21
+ prevTokenExpiresAt: { type: Date, default: null },
19
22
  }, { collection: "sessions", timestamps: false });
23
+ sessionSchema.index({ refreshToken: 1 }, { sparse: true, unique: true });
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,64 @@ 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
+ if (oldRefreshToken) {
239
+ await redis.expire(redisRefreshTokenKey(oldRefreshToken), graceSeconds);
240
+ }
241
+ }
172
242
  // ---------------------------------------------------------------------------
173
243
  // Mongo helpers
174
244
  // ---------------------------------------------------------------------------
@@ -201,6 +271,45 @@ async function mongoGetUserSessions(userId) {
201
271
  }
202
272
  return results;
203
273
  }
274
+ async function mongoSetRefreshToken(sessionId, refreshToken) {
275
+ await getSessionModel().updateOne({ sessionId }, { $set: { refreshToken } });
276
+ }
277
+ async function mongoGetSessionByRefreshToken(refreshToken) {
278
+ const Session = getSessionModel();
279
+ // Check current refresh token
280
+ let doc = await Session.findOne({ refreshToken }).lean();
281
+ if (doc) {
282
+ return { sessionId: doc.sessionId, userId: doc.userId, newRefreshToken: refreshToken };
283
+ }
284
+ // Check previous refresh token (grace window)
285
+ doc = await Session.findOne({ prevRefreshToken: refreshToken }).lean();
286
+ if (!doc)
287
+ return null;
288
+ if (doc.prevTokenExpiresAt && doc.prevTokenExpiresAt > new Date()) {
289
+ // Within grace window — return current refresh token
290
+ return { sessionId: doc.sessionId, userId: doc.userId, newRefreshToken: doc.refreshToken };
291
+ }
292
+ // Grace window expired — token family theft detected, invalidate session
293
+ if (getPersistSessionMetadata()) {
294
+ await Session.updateOne({ sessionId: doc.sessionId }, { $set: { token: null, refreshToken: null, prevRefreshToken: null, prevTokenExpiresAt: null } });
295
+ }
296
+ else {
297
+ await Session.deleteOne({ sessionId: doc.sessionId });
298
+ }
299
+ return null;
300
+ }
301
+ async function mongoRotateRefreshToken(sessionId, newRefreshToken, newAccessToken) {
302
+ const graceSeconds = getRotationGraceSeconds();
303
+ const Session = getSessionModel();
304
+ const doc = await Session.findOne({ sessionId });
305
+ if (!doc)
306
+ return;
307
+ doc.prevRefreshToken = doc.refreshToken;
308
+ doc.prevTokenExpiresAt = new Date(Date.now() + graceSeconds * 1000);
309
+ doc.refreshToken = newRefreshToken;
310
+ doc.token = newAccessToken;
311
+ await doc.save();
312
+ }
204
313
  // ---------------------------------------------------------------------------
205
314
  // Public API
206
315
  // ---------------------------------------------------------------------------
@@ -255,7 +364,7 @@ export const deleteSession = async (sessionId) => {
255
364
  }
256
365
  // mongo
257
366
  if (getPersistSessionMetadata()) {
258
- await getSessionModel().updateOne({ sessionId }, { $set: { token: null } });
367
+ await getSessionModel().updateOne({ sessionId }, { $set: { token: null, refreshToken: null, prevRefreshToken: null, prevTokenExpiresAt: null } });
259
368
  }
260
369
  else {
261
370
  await getSessionModel().deleteOne({ sessionId });
@@ -303,6 +412,10 @@ export const evictOldestSession = async (userId) => {
303
412
  if (oldest)
304
413
  await deleteSession(oldest.sessionId);
305
414
  };
415
+ export const deleteUserSessions = async (userId) => {
416
+ const sessions = await getUserSessions(userId);
417
+ await Promise.all(sessions.map((s) => deleteSession(s.sessionId)));
418
+ };
306
419
  export const updateSessionLastActive = async (sessionId) => {
307
420
  if (_store === "memory") {
308
421
  memoryUpdateSessionLastActive(sessionId);
@@ -319,3 +432,48 @@ export const updateSessionLastActive = async (sessionId) => {
319
432
  // mongo
320
433
  await getSessionModel().updateOne({ sessionId }, { $set: { lastActiveAt: new Date() } });
321
434
  };
435
+ // ---------------------------------------------------------------------------
436
+ // Refresh token API
437
+ // ---------------------------------------------------------------------------
438
+ /** Store a refresh token on an existing session (called after session creation). */
439
+ export const setRefreshToken = async (sessionId, refreshToken) => {
440
+ if (_store === "memory") {
441
+ memorySetRefreshToken(sessionId, refreshToken);
442
+ return;
443
+ }
444
+ if (_store === "sqlite") {
445
+ sqliteSetRefreshToken(sessionId, refreshToken);
446
+ return;
447
+ }
448
+ if (_store === "redis") {
449
+ await redisSetRefreshToken(sessionId, refreshToken);
450
+ return;
451
+ }
452
+ await mongoSetRefreshToken(sessionId, refreshToken);
453
+ };
454
+ /** Look up a session by refresh token. Handles grace window and theft detection. */
455
+ export const getSessionByRefreshToken = async (refreshToken) => {
456
+ if (_store === "memory")
457
+ return memoryGetSessionByRefreshToken(refreshToken);
458
+ if (_store === "sqlite")
459
+ return sqliteGetSessionByRefreshToken(refreshToken);
460
+ if (_store === "redis")
461
+ return redisGetSessionByRefreshToken(refreshToken);
462
+ return mongoGetSessionByRefreshToken(refreshToken);
463
+ };
464
+ /** Rotate the refresh token: move current to prev with grace window, set new token + access token. */
465
+ export const rotateRefreshToken = async (sessionId, newRefreshToken, newAccessToken) => {
466
+ if (_store === "memory") {
467
+ memoryRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
468
+ return;
469
+ }
470
+ if (_store === "sqlite") {
471
+ sqliteRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
472
+ return;
473
+ }
474
+ if (_store === "redis") {
475
+ await redisRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
476
+ return;
477
+ }
478
+ await mongoRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
479
+ };
@@ -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
+ };
@@ -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
+ }
@@ -134,7 +134,10 @@ export const cacheResponse = ({ ttl, key, store = _defaultCacheStore }) => {
134
134
  return async (c, next) => {
135
135
  const appName = getAppName();
136
136
  const rawKey = typeof key === "function" ? key(c) : key;
137
- const cacheKey = `cache:${appName}:${rawKey}`;
137
+ // Per-tenant namespacing: prevents two tenants caching the same key from colliding
138
+ const tenantId = c.get("tenantId");
139
+ const tenantSegment = tenantId ? `${tenantId}:` : "";
140
+ const cacheKey = `cache:${appName}:${tenantSegment}${rawKey}`;
138
141
  const cached = await storeGet(store, cacheKey);
139
142
  if (cached) {
140
143
  const { status, headers, body } = JSON.parse(cached);
@@ -1,8 +1,9 @@
1
1
  import type { MiddlewareHandler } from "hono";
2
+ import type { AppEnv } from "../lib/context";
2
3
  export interface RateLimitOptions {
3
4
  windowMs: number;
4
5
  max: number;
5
6
  /** Also rate-limit by HTTP fingerprint in addition to IP. Default: false */
6
7
  fingerprintLimit?: boolean;
7
8
  }
8
- export declare const rateLimit: ({ windowMs, max, fingerprintLimit, }: RateLimitOptions) => MiddlewareHandler;
9
+ export declare const rateLimit: ({ windowMs, max, fingerprintLimit, }: RateLimitOptions) => MiddlewareHandler<AppEnv>;
@@ -6,12 +6,15 @@ export const rateLimit = ({ windowMs, max, fingerprintLimit = false, }) => {
6
6
  // Take the leftmost (client) IP from x-forwarded-for
7
7
  const raw = c.req.header("x-forwarded-for") ?? "";
8
8
  const ip = raw.split(",")[0]?.trim() || "unknown";
9
- if (await trackAttempt(`ip:${ip}`, opts)) {
9
+ // Per-tenant namespacing: each tenant gets independent rate limit buckets
10
+ const tenantId = c.get("tenantId");
11
+ const prefix = tenantId ? `t:${tenantId}:` : "";
12
+ if (await trackAttempt(`${prefix}ip:${ip}`, opts)) {
10
13
  return c.json({ error: "Too Many Requests" }, 429);
11
14
  }
12
15
  if (fingerprintLimit) {
13
16
  const fp = await buildFingerprint(c.req.raw);
14
- if (await trackAttempt(`fp:${fp}`, opts)) {
17
+ if (await trackAttempt(`${prefix}fp:${fp}`, opts)) {
15
18
  return c.json({ error: "Too Many Requests" }, 429);
16
19
  }
17
20
  }
@@ -3,9 +3,11 @@ import type { AppEnv } from "../lib/context";
3
3
  /**
4
4
  * Middleware factory that enforces role-based access.
5
5
  * Requires `identify` to have run first (authUserId must be set).
6
- * Roles are fetched lazily on the first role-checked route and cached on the context.
7
6
  *
8
- * The adapter must implement `getRoles` for this to work.
7
+ * When tenant context exists (`tenantId` set on context), checks tenant-scoped roles.
8
+ * Falls back to app-wide roles when no tenant context is present.
9
+ *
10
+ * The adapter must implement `getRoles` (and `getTenantRoles` for tenant-scoped checks).
9
11
  *
10
12
  * @example
11
13
  * // Allow any authenticated user with the "admin" role
@@ -14,4 +16,13 @@ import type { AppEnv } from "../lib/context";
14
16
  * // Allow users with either "admin" or "moderator"
15
17
  * app.get("/mod", userAuth, requireRole("admin", "moderator"), handler)
16
18
  */
17
- export declare const requireRole: (...roles: string[]) => MiddlewareHandler<AppEnv>;
19
+ export declare const requireRole: ((...roles: string[]) => MiddlewareHandler<AppEnv>) & {
20
+ /**
21
+ * Always checks app-wide roles regardless of tenant context.
22
+ * Use for super-admin gates that should ignore tenant scoping.
23
+ *
24
+ * @example
25
+ * app.get("/super-admin", userAuth, requireRole.global("superadmin"), handler)
26
+ */
27
+ global: (...roles: string[]) => MiddlewareHandler<AppEnv>;
28
+ };
@@ -2,9 +2,11 @@ import { getAuthAdapter } from "../lib/authAdapter";
2
2
  /**
3
3
  * Middleware factory that enforces role-based access.
4
4
  * Requires `identify` to have run first (authUserId must be set).
5
- * Roles are fetched lazily on the first role-checked route and cached on the context.
6
5
  *
7
- * The adapter must implement `getRoles` for this to work.
6
+ * When tenant context exists (`tenantId` set on context), checks tenant-scoped roles.
7
+ * Falls back to app-wide roles when no tenant context is present.
8
+ *
9
+ * The adapter must implement `getRoles` (and `getTenantRoles` for tenant-scoped checks).
8
10
  *
9
11
  * @example
10
12
  * // Allow any authenticated user with the "admin" role
@@ -13,15 +15,25 @@ import { getAuthAdapter } from "../lib/authAdapter";
13
15
  * // Allow users with either "admin" or "moderator"
14
16
  * app.get("/mod", userAuth, requireRole("admin", "moderator"), handler)
15
17
  */
16
- export const requireRole = (...roles) => async (c, next) => {
18
+ export const requireRole = Object.assign((...roles) => async (c, next) => {
17
19
  const userId = c.get("authUserId");
18
20
  if (!userId) {
19
21
  return c.json({ error: "Unauthorized" }, 401);
20
22
  }
21
- // Lazy-fetch roles and cache on context so multiple requireRole calls in a chain only hit the adapter once
23
+ const adapter = getAuthAdapter();
24
+ const tenantId = c.get("tenantId");
25
+ // When tenant context exists and adapter supports tenant roles, check tenant-scoped roles
26
+ if (tenantId && adapter.getTenantRoles) {
27
+ const tenantRoles = await adapter.getTenantRoles(userId, tenantId);
28
+ const hasRole = roles.some((role) => tenantRoles.includes(role));
29
+ if (!hasRole) {
30
+ return c.json({ error: "Forbidden" }, 403);
31
+ }
32
+ return next();
33
+ }
34
+ // Fall back to app-wide roles
22
35
  let userRoles = c.get("roles");
23
36
  if (userRoles === null) {
24
- const adapter = getAuthAdapter();
25
37
  if (!adapter.getRoles) {
26
38
  throw new Error("requireRole used but auth adapter does not implement getRoles");
27
39
  }
@@ -33,4 +45,32 @@ export const requireRole = (...roles) => async (c, next) => {
33
45
  return c.json({ error: "Forbidden" }, 403);
34
46
  }
35
47
  await next();
36
- };
48
+ }, {
49
+ /**
50
+ * Always checks app-wide roles regardless of tenant context.
51
+ * Use for super-admin gates that should ignore tenant scoping.
52
+ *
53
+ * @example
54
+ * app.get("/super-admin", userAuth, requireRole.global("superadmin"), handler)
55
+ */
56
+ global: (...roles) => async (c, next) => {
57
+ const userId = c.get("authUserId");
58
+ if (!userId) {
59
+ return c.json({ error: "Unauthorized" }, 401);
60
+ }
61
+ let userRoles = c.get("roles");
62
+ if (userRoles === null) {
63
+ const adapter = getAuthAdapter();
64
+ if (!adapter.getRoles) {
65
+ throw new Error("requireRole.global used but auth adapter does not implement getRoles");
66
+ }
67
+ userRoles = await adapter.getRoles(userId);
68
+ c.set("roles", userRoles);
69
+ }
70
+ const hasRole = roles.some((role) => userRoles.includes(role));
71
+ if (!hasRole) {
72
+ return c.json({ error: "Forbidden" }, 403);
73
+ }
74
+ await next();
75
+ },
76
+ });
@@ -0,0 +1,5 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { AppEnv } from "../lib/context";
3
+ import type { TenancyConfig } from "../app";
4
+ export declare const invalidateTenantCache: (tenantId: string) => void;
5
+ export declare const createTenantMiddleware: (config: TenancyConfig) => MiddlewareHandler<AppEnv>;