@lastshotlabs/bunshot 0.0.21 → 0.0.27

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 (185) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +59 -0
  4. package/dist/adapters/memoryAuth.d.ts +13 -0
  5. package/dist/adapters/memoryAuth.js +261 -2
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +217 -1
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +30 -0
  12. package/dist/adapters/sqliteAuth.js +352 -2
  13. package/dist/app.d.ts +203 -3
  14. package/dist/app.js +352 -48
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +69 -8
  17. package/dist/index.js +46 -5
  18. package/dist/lib/HttpError.d.ts +7 -1
  19. package/dist/lib/HttpError.js +10 -1
  20. package/dist/lib/appConfig.d.ts +157 -0
  21. package/dist/lib/appConfig.js +54 -0
  22. package/dist/lib/auditLog.d.ts +58 -0
  23. package/dist/lib/auditLog.js +218 -0
  24. package/dist/lib/authAdapter.d.ts +140 -1
  25. package/dist/lib/authRateLimit.js +36 -0
  26. package/dist/lib/breachedPassword.d.ts +13 -0
  27. package/dist/lib/breachedPassword.js +48 -0
  28. package/dist/lib/captcha.d.ts +25 -0
  29. package/dist/lib/captcha.js +37 -0
  30. package/dist/lib/constants.d.ts +4 -0
  31. package/dist/lib/constants.js +4 -0
  32. package/dist/lib/context.d.ts +24 -1
  33. package/dist/lib/context.js +17 -3
  34. package/dist/lib/createRoute.d.ts +28 -2
  35. package/dist/lib/createRoute.js +54 -3
  36. package/dist/lib/credentialStuffing.d.ts +31 -0
  37. package/dist/lib/credentialStuffing.js +77 -0
  38. package/dist/lib/deletionCancelToken.d.ts +12 -0
  39. package/dist/lib/deletionCancelToken.js +88 -0
  40. package/dist/lib/emailVerification.d.ts +6 -0
  41. package/dist/lib/emailVerification.js +46 -3
  42. package/dist/lib/groups.d.ts +113 -0
  43. package/dist/lib/groups.js +133 -0
  44. package/dist/lib/idempotency.d.ts +22 -0
  45. package/dist/lib/idempotency.js +182 -0
  46. package/dist/lib/jwks.d.ts +25 -0
  47. package/dist/lib/jwks.js +51 -0
  48. package/dist/lib/jwt.d.ts +15 -2
  49. package/dist/lib/jwt.js +92 -5
  50. package/dist/lib/logger.d.ts +2 -0
  51. package/dist/lib/logger.js +6 -0
  52. package/dist/lib/m2m.d.ts +29 -0
  53. package/dist/lib/m2m.js +48 -0
  54. package/dist/lib/metrics.d.ts +14 -0
  55. package/dist/lib/metrics.js +158 -0
  56. package/dist/lib/mfaChallenge.d.ts +14 -1
  57. package/dist/lib/mfaChallenge.js +111 -6
  58. package/dist/lib/mongo.js +1 -1
  59. package/dist/lib/oauthCode.js +23 -18
  60. package/dist/lib/pagination.d.ts +119 -0
  61. package/dist/lib/pagination.js +166 -0
  62. package/dist/lib/resetPassword.js +3 -1
  63. package/dist/lib/saml.d.ts +25 -0
  64. package/dist/lib/saml.js +64 -0
  65. package/dist/lib/scim.d.ts +44 -0
  66. package/dist/lib/scim.js +54 -0
  67. package/dist/lib/securityEvents.d.ts +28 -0
  68. package/dist/lib/securityEvents.js +26 -0
  69. package/dist/lib/session.d.ts +14 -0
  70. package/dist/lib/session.js +121 -5
  71. package/dist/lib/signing.d.ts +52 -0
  72. package/dist/lib/signing.js +183 -0
  73. package/dist/lib/storageAdapter.d.ts +30 -0
  74. package/dist/lib/storageAdapter.js +1 -0
  75. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  76. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  77. package/dist/lib/suspension.d.ts +13 -0
  78. package/dist/lib/suspension.js +23 -0
  79. package/dist/lib/tenant.js +2 -2
  80. package/dist/lib/upload.d.ts +39 -0
  81. package/dist/lib/upload.js +112 -0
  82. package/dist/lib/uploadRegistry.d.ts +18 -0
  83. package/dist/lib/uploadRegistry.js +83 -0
  84. package/dist/lib/validate.js +2 -2
  85. package/dist/lib/ws.d.ts +1 -0
  86. package/dist/lib/ws.js +28 -0
  87. package/dist/lib/wsHeartbeat.d.ts +12 -0
  88. package/dist/lib/wsHeartbeat.js +57 -0
  89. package/dist/lib/wsMessages.d.ts +40 -0
  90. package/dist/lib/wsMessages.js +330 -0
  91. package/dist/lib/wsPresence.d.ts +25 -0
  92. package/dist/lib/wsPresence.js +99 -0
  93. package/dist/middleware/auditLog.d.ts +22 -0
  94. package/dist/middleware/auditLog.js +39 -0
  95. package/dist/middleware/bearerAuth.js +1 -1
  96. package/dist/middleware/cacheResponse.js +5 -1
  97. package/dist/middleware/captcha.d.ts +10 -0
  98. package/dist/middleware/captcha.js +36 -0
  99. package/dist/middleware/csrf.js +18 -4
  100. package/dist/middleware/errorHandler.js +4 -1
  101. package/dist/middleware/identify.js +89 -14
  102. package/dist/middleware/metrics.d.ts +9 -0
  103. package/dist/middleware/metrics.js +26 -0
  104. package/dist/middleware/requestId.d.ts +3 -0
  105. package/dist/middleware/requestId.js +7 -0
  106. package/dist/middleware/requestLogger.d.ts +38 -0
  107. package/dist/middleware/requestLogger.js +68 -0
  108. package/dist/middleware/requestSigning.d.ts +20 -0
  109. package/dist/middleware/requestSigning.js +100 -0
  110. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  111. package/dist/middleware/requireMfaSetup.js +37 -0
  112. package/dist/middleware/requireRole.d.ts +9 -3
  113. package/dist/middleware/requireRole.js +23 -36
  114. package/dist/middleware/requireScope.d.ts +10 -0
  115. package/dist/middleware/requireScope.js +25 -0
  116. package/dist/middleware/requireStepUp.d.ts +18 -0
  117. package/dist/middleware/requireStepUp.js +29 -0
  118. package/dist/middleware/scimAuth.d.ts +8 -0
  119. package/dist/middleware/scimAuth.js +29 -0
  120. package/dist/middleware/upload.d.ts +5 -0
  121. package/dist/middleware/upload.js +27 -0
  122. package/dist/middleware/webhookAuth.d.ts +30 -0
  123. package/dist/middleware/webhookAuth.js +58 -0
  124. package/dist/models/AuditLog.d.ts +30 -0
  125. package/dist/models/AuditLog.js +39 -0
  126. package/dist/models/AuthUser.d.ts +7 -0
  127. package/dist/models/AuthUser.js +7 -0
  128. package/dist/models/Group.d.ts +21 -0
  129. package/dist/models/Group.js +28 -0
  130. package/dist/models/GroupMembership.d.ts +21 -0
  131. package/dist/models/GroupMembership.js +25 -0
  132. package/dist/models/M2MClient.d.ts +18 -0
  133. package/dist/models/M2MClient.js +18 -0
  134. package/dist/routes/auth.d.ts +3 -2
  135. package/dist/routes/auth.js +238 -21
  136. package/dist/routes/groups.d.ts +21 -0
  137. package/dist/routes/groups.js +346 -0
  138. package/dist/routes/jobs.js +66 -46
  139. package/dist/routes/m2m.d.ts +2 -0
  140. package/dist/routes/m2m.js +72 -0
  141. package/dist/routes/metrics.d.ts +8 -0
  142. package/dist/routes/metrics.js +55 -0
  143. package/dist/routes/mfa.js +13 -1
  144. package/dist/routes/oauth.js +6 -0
  145. package/dist/routes/oidc.d.ts +2 -0
  146. package/dist/routes/oidc.js +29 -0
  147. package/dist/routes/passkey.d.ts +1 -0
  148. package/dist/routes/passkey.js +157 -0
  149. package/dist/routes/saml.d.ts +2 -0
  150. package/dist/routes/saml.js +86 -0
  151. package/dist/routes/scim.d.ts +2 -0
  152. package/dist/routes/scim.js +255 -0
  153. package/dist/routes/uploads.d.ts +14 -0
  154. package/dist/routes/uploads.js +227 -0
  155. package/dist/server.d.ts +26 -0
  156. package/dist/server.js +46 -3
  157. package/dist/services/auth.d.ts +2 -0
  158. package/dist/services/auth.js +101 -22
  159. package/dist/services/mfa.js +2 -2
  160. package/dist/ws/index.js +5 -1
  161. package/docs/sections/auth-flow/full.md +203 -47
  162. package/docs/sections/auth-flow/overview.md +2 -2
  163. package/docs/sections/auth-security-examples/full.md +388 -0
  164. package/docs/sections/authentication/full.md +130 -0
  165. package/docs/sections/authentication/overview.md +5 -0
  166. package/docs/sections/cli/full.md +13 -1
  167. package/docs/sections/configuration/full.md +17 -0
  168. package/docs/sections/configuration/overview.md +1 -0
  169. package/docs/sections/exports/full.md +34 -3
  170. package/docs/sections/logging/full.md +83 -0
  171. package/docs/sections/metrics/full.md +131 -0
  172. package/docs/sections/oauth/full.md +189 -189
  173. package/docs/sections/oauth/overview.md +1 -1
  174. package/docs/sections/pagination/full.md +93 -0
  175. package/docs/sections/passkey-login/full.md +90 -0
  176. package/docs/sections/passkey-login/overview.md +1 -0
  177. package/docs/sections/roles/full.md +224 -135
  178. package/docs/sections/roles/overview.md +3 -1
  179. package/docs/sections/signing/full.md +203 -0
  180. package/docs/sections/uploads/full.md +208 -0
  181. package/docs/sections/versioning/full.md +85 -0
  182. package/docs/sections/webhook-auth/full.md +100 -0
  183. package/docs/sections/websocket/full.md +95 -0
  184. package/docs/sections/websocket-rooms/full.md +6 -1
  185. package/package.json +18 -5
@@ -1,8 +1,9 @@
1
1
  import { getRedis } from "./redis";
2
2
  import { appConnection, mongoose } from "./mongo";
3
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";
4
+ import { timingSafeEqual } from "./crypto";
5
+ import { sqliteCreateSession, sqliteGetSession, sqliteDeleteSession, sqliteGetUserSessions, sqliteGetActiveSessionCount, sqliteEvictOldestSession, sqliteUpdateSessionLastActive, sqliteSetRefreshToken, sqliteGetSessionByRefreshToken, sqliteRotateRefreshToken, sqliteGetSessionFingerprint, sqliteSetSessionFingerprint, sqliteGetMfaVerifiedAt, sqliteSetMfaVerifiedAt, } from "../adapters/sqliteAuth";
6
+ import { memoryCreateSession, memoryGetSession, memoryDeleteSession, memoryGetUserSessions, memoryGetActiveSessionCount, memoryEvictOldestSession, memoryUpdateSessionLastActive, memorySetRefreshToken, memoryGetSessionByRefreshToken, memoryRotateRefreshToken, memoryGetSessionFingerprint, memorySetSessionFingerprint, memoryGetMfaVerifiedAt, memorySetMfaVerifiedAt, } from "../adapters/memoryAuth";
6
7
  function getSessionModel() {
7
8
  if (appConnection.models["Session"])
8
9
  return appConnection.models["Session"];
@@ -19,6 +20,8 @@ function getSessionModel() {
19
20
  refreshToken: { type: String, default: null },
20
21
  prevRefreshToken: { type: String, default: null },
21
22
  prevTokenExpiresAt: { type: Date, default: null },
23
+ fingerprint: { type: String, default: null },
24
+ mfaVerifiedAt: { type: Number, default: null },
22
25
  }, { collection: "sessions", timestamps: false });
23
26
  sessionSchema.index({ refreshToken: 1 }, { unique: true, partialFilterExpression: { refreshToken: { $type: "string" } } });
24
27
  // Add TTL index only when metadata is not persisted — docs auto-delete at expiresAt.
@@ -202,16 +205,16 @@ async function redisGetSessionByRefreshToken(refreshToken) {
202
205
  return null;
203
206
  const rec = JSON.parse(raw);
204
207
  // Current refresh token matches
205
- if (rec.refreshToken === refreshToken) {
208
+ if (timingSafeEqual(rec.refreshToken ?? "", refreshToken)) {
206
209
  return { sessionId: rec.sessionId, userId: rec.userId, newRefreshToken: refreshToken };
207
210
  }
208
211
  // Check grace window: old token used within grace period
209
- if (rec.prevRefreshToken === refreshToken && rec.prevTokenExpiresAt && rec.prevTokenExpiresAt > Date.now()) {
212
+ if (timingSafeEqual(rec.prevRefreshToken ?? "", refreshToken) && rec.prevTokenExpiresAt && rec.prevTokenExpiresAt > Date.now()) {
210
213
  // Return current refresh token — client missed the rotation response
211
214
  return { sessionId: rec.sessionId, userId: rec.userId, newRefreshToken: rec.refreshToken };
212
215
  }
213
216
  // Old token used after grace window — token family theft detected, invalidate session
214
- if (rec.prevRefreshToken === refreshToken) {
217
+ if (timingSafeEqual(rec.prevRefreshToken ?? "", refreshToken)) {
215
218
  await redisDeleteSession(sessionId);
216
219
  return null;
217
220
  }
@@ -479,3 +482,116 @@ export const rotateRefreshToken = async (sessionId, newRefreshToken, newAccessTo
479
482
  }
480
483
  await mongoRotateRefreshToken(sessionId, newRefreshToken, newAccessToken);
481
484
  };
485
+ // ---------------------------------------------------------------------------
486
+ // Session fingerprint API (session binding feature)
487
+ // ---------------------------------------------------------------------------
488
+ /** Read the stored fingerprint for a session. Returns null if not yet set. */
489
+ export const getSessionFingerprint = async (sessionId) => {
490
+ if (_store === "memory")
491
+ return memoryGetSessionFingerprint(sessionId);
492
+ if (_store === "sqlite")
493
+ return sqliteGetSessionFingerprint(sessionId);
494
+ if (_store === "redis") {
495
+ const redis = getRedis();
496
+ const raw = await redis.get(redisSessionKey(sessionId));
497
+ if (!raw)
498
+ return null;
499
+ const rec = JSON.parse(raw);
500
+ return rec.fingerprint ?? null;
501
+ }
502
+ // mongo
503
+ const doc = await getSessionModel().findOne({ sessionId }, "fingerprint").lean();
504
+ return doc?.fingerprint ?? null;
505
+ };
506
+ /** Store a fingerprint on an existing session. No-op if the session does not exist. */
507
+ export const setSessionFingerprint = async (sessionId, fingerprint) => {
508
+ if (_store === "memory") {
509
+ memorySetSessionFingerprint(sessionId, fingerprint);
510
+ return;
511
+ }
512
+ if (_store === "sqlite") {
513
+ sqliteSetSessionFingerprint(sessionId, fingerprint);
514
+ return;
515
+ }
516
+ if (_store === "redis") {
517
+ const redis = getRedis();
518
+ const raw = await redis.get(redisSessionKey(sessionId));
519
+ if (!raw)
520
+ return;
521
+ const rec = JSON.parse(raw);
522
+ rec.fingerprint = fingerprint;
523
+ if (getPersistSessionMetadata()) {
524
+ await redis.set(redisSessionKey(sessionId), JSON.stringify(rec));
525
+ }
526
+ else {
527
+ const now = Date.now();
528
+ if (rec.expiresAt <= now)
529
+ return;
530
+ const ttlRemaining = Math.max(1, Math.ceil((rec.expiresAt - now) / 1000));
531
+ await redis.set(redisSessionKey(sessionId), JSON.stringify(rec), "EX", ttlRemaining);
532
+ }
533
+ return;
534
+ }
535
+ // mongo
536
+ await getSessionModel().updateOne({ sessionId }, { $set: { fingerprint } });
537
+ };
538
+ // ---------------------------------------------------------------------------
539
+ // Step-up MFA API (mfaVerifiedAt)
540
+ // ---------------------------------------------------------------------------
541
+ /**
542
+ * Store the timestamp when MFA was last verified in the session metadata.
543
+ * Used by requireStepUp middleware.
544
+ */
545
+ export const setMfaVerifiedAt = async (sessionId) => {
546
+ const now = Math.floor(Date.now() / 1000);
547
+ if (_store === "memory") {
548
+ memorySetMfaVerifiedAt(sessionId, now);
549
+ return;
550
+ }
551
+ if (_store === "sqlite") {
552
+ sqliteSetMfaVerifiedAt(sessionId, now);
553
+ return;
554
+ }
555
+ if (_store === "redis") {
556
+ const redis = getRedis();
557
+ const raw = await redis.get(redisSessionKey(sessionId));
558
+ if (!raw)
559
+ return;
560
+ const rec = JSON.parse(raw);
561
+ rec.mfaVerifiedAt = now;
562
+ if (getPersistSessionMetadata()) {
563
+ await redis.set(redisSessionKey(sessionId), JSON.stringify(rec));
564
+ }
565
+ else {
566
+ const nowMs = Date.now();
567
+ if (rec.expiresAt <= nowMs)
568
+ return;
569
+ const ttlRemaining = Math.max(1, Math.ceil((rec.expiresAt - nowMs) / 1000));
570
+ await redis.set(redisSessionKey(sessionId), JSON.stringify(rec), "EX", ttlRemaining);
571
+ }
572
+ return;
573
+ }
574
+ // mongo
575
+ await getSessionModel().updateOne({ sessionId }, { $set: { mfaVerifiedAt: now } });
576
+ };
577
+ /**
578
+ * Get the Unix timestamp (seconds) when MFA was last verified for this session.
579
+ * Returns null if MFA has never been verified or session not found.
580
+ */
581
+ export const getMfaVerifiedAt = async (sessionId) => {
582
+ if (_store === "memory")
583
+ return memoryGetMfaVerifiedAt(sessionId);
584
+ if (_store === "sqlite")
585
+ return sqliteGetMfaVerifiedAt(sessionId);
586
+ if (_store === "redis") {
587
+ const redis = getRedis();
588
+ const raw = await redis.get(redisSessionKey(sessionId));
589
+ if (!raw)
590
+ return null;
591
+ const rec = JSON.parse(raw);
592
+ return rec.mfaVerifiedAt ?? null;
593
+ }
594
+ // mongo
595
+ const doc = await getSessionModel().findOne({ sessionId }, "mfaVerifiedAt").lean();
596
+ return doc?.mfaVerifiedAt ?? null;
597
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Sign `data` with the active key (first element of `secret`).
3
+ * Normalizes string | string[] so that an array is never passed directly to
4
+ * createHmac() — which would silently call .toString() and produce
5
+ * "[object Array]" as the key.
6
+ */
7
+ export declare function hmacSign(data: string, secret: string | string[]): string;
8
+ /**
9
+ * Verify `sig` against `data` using one of the provided keys.
10
+ * Keys are tried newest-first (index 0 is the active signing key).
11
+ *
12
+ * Key ordering convention: put the current (newest) key first; rotated keys
13
+ * after. The common case (valid current-key signature) succeeds on the first
14
+ * comparison; old rotated keys only matter for in-flight tokens.
15
+ *
16
+ * MUST use timingSafeEqual — never === — to prevent timing side-channel leaks.
17
+ * This is the most common HMAC implementation mistake.
18
+ */
19
+ export declare function hmacVerify(data: string, sig: string, secret: string | string[]): boolean;
20
+ /** Returns `"base64url(value).hmac"`. */
21
+ export declare function signCookieValue(value: string, secret: string | string[]): string;
22
+ /** Returns the original value or `null` if the signature is invalid. */
23
+ export declare function verifyCookieValue(signed: string, secret: string | string[]): string | null;
24
+ /** Returns `"base64url(payload).hmac"`. */
25
+ export declare function signCursor(payload: string, secret: string | string[]): string;
26
+ /** Returns the original payload or `null` if the signature is invalid. */
27
+ export declare function verifyCursor(cursor: string, secret: string | string[]): string | null;
28
+ /**
29
+ * Create a stateless HMAC-signed URL. The signature covers the HTTP method,
30
+ * storage key, and expiry timestamp so that:
31
+ * - Expired URLs are rejected (replay prevention)
32
+ * - URLs are method-bound (a GET URL can't be replayed as a PUT)
33
+ * - Tampering with the key or expiry invalidates the signature
34
+ *
35
+ * @param base Base URL string (e.g. "https://api.example.com/uploads/presign")
36
+ * @param key Storage object key
37
+ * @param opts Method, expiry in seconds from now, optional extra query params
38
+ * @param secret HMAC secret (supports key rotation via string[])
39
+ */
40
+ export declare function createPresignedUrl(base: string, key: string, opts: {
41
+ method: string;
42
+ expiry: number;
43
+ extra?: Record<string, string>;
44
+ }, secret: string | string[]): string;
45
+ /**
46
+ * Verify an HMAC-signed URL. Returns the key and any extra params, or null
47
+ * if the URL is expired, tampered, or method-mismatched.
48
+ */
49
+ export declare function verifyPresignedUrl(url: string, method: string, secret: string | string[]): {
50
+ key: string;
51
+ extra?: Record<string, string>;
52
+ } | null;
@@ -0,0 +1,183 @@
1
+ import { createHmac } from "crypto";
2
+ import { timingSafeEqual } from "./crypto";
3
+ // ---------------------------------------------------------------------------
4
+ // Core HMAC primitives
5
+ // ---------------------------------------------------------------------------
6
+ /**
7
+ * Sign `data` with the active key (first element of `secret`).
8
+ * Normalizes string | string[] so that an array is never passed directly to
9
+ * createHmac() — which would silently call .toString() and produce
10
+ * "[object Array]" as the key.
11
+ */
12
+ export function hmacSign(data, secret) {
13
+ const key = Array.isArray(secret) ? secret[0] : secret;
14
+ return createHmac("sha256", key).update(data).digest("hex");
15
+ }
16
+ /**
17
+ * Verify `sig` against `data` using one of the provided keys.
18
+ * Keys are tried newest-first (index 0 is the active signing key).
19
+ *
20
+ * Key ordering convention: put the current (newest) key first; rotated keys
21
+ * after. The common case (valid current-key signature) succeeds on the first
22
+ * comparison; old rotated keys only matter for in-flight tokens.
23
+ *
24
+ * MUST use timingSafeEqual — never === — to prevent timing side-channel leaks.
25
+ * This is the most common HMAC implementation mistake.
26
+ */
27
+ export function hmacVerify(data, sig, secret) {
28
+ const keys = Array.isArray(secret) ? secret : [secret];
29
+ for (const key of keys) {
30
+ const expected = createHmac("sha256", key).update(data).digest("hex");
31
+ try {
32
+ if (timingSafeEqual(expected, sig))
33
+ return true;
34
+ }
35
+ catch {
36
+ // timingSafeEqual (src/lib/crypto.ts) handles length mismatches itself:
37
+ // it returns false rather than throwing, so this catch block is never
38
+ // reached under normal conditions. It is kept as a defensive no-op in
39
+ // case the underlying implementation changes in the future.
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Cookie signing
46
+ //
47
+ // Value is base64url-encoded before appending ".sig" to avoid delimiter
48
+ // collision — raw values may contain "." which would break naive
49
+ // split-on-last-dot parsing.
50
+ //
51
+ // Edge case: base64url("") === "" so the signed form for an empty value is
52
+ // ".sig". Split uses lastIndexOf("."), not indexOf("."), and dotIdx === 0
53
+ // is treated as a valid (empty) value, not a parse error.
54
+ // ---------------------------------------------------------------------------
55
+ function toBase64url(s) {
56
+ return Buffer.from(s).toString("base64url");
57
+ }
58
+ function fromBase64url(s) {
59
+ return Buffer.from(s, "base64url").toString("utf8");
60
+ }
61
+ /** Returns `"base64url(value).hmac"`. */
62
+ export function signCookieValue(value, secret) {
63
+ const encoded = toBase64url(value);
64
+ const sig = hmacSign(encoded, secret);
65
+ return `${encoded}.${sig}`;
66
+ }
67
+ /** Returns the original value or `null` if the signature is invalid. */
68
+ export function verifyCookieValue(signed, secret) {
69
+ const dotIdx = signed.lastIndexOf(".");
70
+ // dotIdx === 0 is valid: empty encoded value (signed form ".sig")
71
+ if (dotIdx < 0)
72
+ return null;
73
+ const encoded = signed.slice(0, dotIdx);
74
+ const sig = signed.slice(dotIdx + 1);
75
+ if (!hmacVerify(encoded, sig, secret))
76
+ return null;
77
+ try {
78
+ return fromBase64url(encoded);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // Cursor signing (same structure as cookie signing)
86
+ // ---------------------------------------------------------------------------
87
+ /** Returns `"base64url(payload).hmac"`. */
88
+ export function signCursor(payload, secret) {
89
+ const encoded = toBase64url(payload);
90
+ const sig = hmacSign(encoded, secret);
91
+ return `${encoded}.${sig}`;
92
+ }
93
+ /** Returns the original payload or `null` if the signature is invalid. */
94
+ export function verifyCursor(cursor, secret) {
95
+ const dotIdx = cursor.lastIndexOf(".");
96
+ if (dotIdx < 0)
97
+ return null;
98
+ const encoded = cursor.slice(0, dotIdx);
99
+ const sig = cursor.slice(dotIdx + 1);
100
+ if (!hmacVerify(encoded, sig, secret))
101
+ return null;
102
+ try {
103
+ return fromBase64url(encoded);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ // ---------------------------------------------------------------------------
110
+ // Presigned URLs
111
+ //
112
+ // Signing data = method + "\n" + key + "\n" + exp
113
+ // Newline delimiter is safe: keys like "uploads/2024/photo.jpg" contain dots
114
+ // but cannot contain newlines; method and exp never contain newlines.
115
+ // Using "." would create ambiguity with keys containing dots.
116
+ // ---------------------------------------------------------------------------
117
+ /**
118
+ * Create a stateless HMAC-signed URL. The signature covers the HTTP method,
119
+ * storage key, and expiry timestamp so that:
120
+ * - Expired URLs are rejected (replay prevention)
121
+ * - URLs are method-bound (a GET URL can't be replayed as a PUT)
122
+ * - Tampering with the key or expiry invalidates the signature
123
+ *
124
+ * @param base Base URL string (e.g. "https://api.example.com/uploads/presign")
125
+ * @param key Storage object key
126
+ * @param opts Method, expiry in seconds from now, optional extra query params
127
+ * @param secret HMAC secret (supports key rotation via string[])
128
+ */
129
+ export function createPresignedUrl(base, key, opts, secret) {
130
+ const exp = Math.floor(Date.now() / 1000) + opts.expiry;
131
+ const method = opts.method.toUpperCase();
132
+ const data = `${method}\n${key}\n${exp}`;
133
+ const sig = hmacSign(data, secret);
134
+ const url = new URL(base);
135
+ url.searchParams.set("key", key);
136
+ url.searchParams.set("exp", String(exp));
137
+ url.searchParams.set("method", method);
138
+ url.searchParams.set("sig", sig);
139
+ if (opts.extra) {
140
+ for (const [k, v] of Object.entries(opts.extra)) {
141
+ url.searchParams.set(k, v);
142
+ }
143
+ }
144
+ return url.toString();
145
+ }
146
+ /**
147
+ * Verify an HMAC-signed URL. Returns the key and any extra params, or null
148
+ * if the URL is expired, tampered, or method-mismatched.
149
+ */
150
+ export function verifyPresignedUrl(url, method, secret) {
151
+ let parsedUrl;
152
+ try {
153
+ parsedUrl = new URL(url);
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ const key = parsedUrl.searchParams.get("key");
159
+ const exp = parsedUrl.searchParams.get("exp");
160
+ const sig = parsedUrl.searchParams.get("sig");
161
+ const urlMethod = parsedUrl.searchParams.get("method");
162
+ if (!key || !exp || !sig || !urlMethod)
163
+ return null;
164
+ // Method binding check
165
+ if (urlMethod !== method.toUpperCase())
166
+ return null;
167
+ // Expiry check
168
+ const expNum = parseInt(exp, 10);
169
+ if (!isFinite(expNum) || expNum < Math.floor(Date.now() / 1000))
170
+ return null;
171
+ // Signature check
172
+ const data = `${urlMethod}\n${key}\n${exp}`;
173
+ if (!hmacVerify(data, sig, secret))
174
+ return null;
175
+ // Collect extra params (all except reserved ones)
176
+ const reserved = new Set(["key", "exp", "sig", "method"]);
177
+ const extra = {};
178
+ for (const [k, v] of parsedUrl.searchParams.entries()) {
179
+ if (!reserved.has(k))
180
+ extra[k] = v;
181
+ }
182
+ return Object.keys(extra).length > 0 ? { key, extra } : { key };
183
+ }
@@ -0,0 +1,30 @@
1
+ export interface StorageAdapter {
2
+ put(key: string, data: Blob | Buffer | ReadableStream, meta: {
3
+ mimeType: string;
4
+ size: number;
5
+ bucket?: string;
6
+ }): Promise<{
7
+ url?: string;
8
+ }>;
9
+ get(key: string): Promise<{
10
+ stream: ReadableStream;
11
+ mimeType?: string;
12
+ size?: number;
13
+ } | null>;
14
+ delete(key: string): Promise<void>;
15
+ presignPut?(key: string, opts: {
16
+ expirySeconds: number;
17
+ mimeType?: string;
18
+ maxSize?: number;
19
+ }): Promise<string>;
20
+ presignGet?(key: string, opts: {
21
+ expirySeconds: number;
22
+ }): Promise<string>;
23
+ }
24
+ export interface UploadResult {
25
+ key: string;
26
+ originalName: string;
27
+ mimeType: string;
28
+ size: number;
29
+ url?: string;
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Post-processes an OpenAPI 3.x spec object to remove `components/schemas` entries
3
+ * not directly or transitively referenced by any path operation.
4
+ *
5
+ * Prevents phantom types in generated TypeScript clients (openapi-typescript, orval)
6
+ * when multiple versioned specs share a single OpenAPI registry.
7
+ *
8
+ * @param spec - The OpenAPI spec document (from `app.getOpenAPIDocument()`).
9
+ * @returns A shallow-cloned spec with unreferenced schemas removed.
10
+ */
11
+ export declare function stripUnreferencedSchemas(spec: Record<string, any>): Record<string, any>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Post-processes an OpenAPI 3.x spec object to remove `components/schemas` entries
3
+ * not directly or transitively referenced by any path operation.
4
+ *
5
+ * Prevents phantom types in generated TypeScript clients (openapi-typescript, orval)
6
+ * when multiple versioned specs share a single OpenAPI registry.
7
+ *
8
+ * @param spec - The OpenAPI spec document (from `app.getOpenAPIDocument()`).
9
+ * @returns A shallow-cloned spec with unreferenced schemas removed.
10
+ */
11
+ export function stripUnreferencedSchemas(spec) {
12
+ const schemas = spec?.components?.schemas;
13
+ if (!schemas || typeof schemas !== "object")
14
+ return spec;
15
+ // Collect all $ref strings from an arbitrary JSON node
16
+ function collectRefs(node, refs) {
17
+ if (!node || typeof node !== "object")
18
+ return;
19
+ if (Array.isArray(node)) {
20
+ for (const item of node)
21
+ collectRefs(item, refs);
22
+ return;
23
+ }
24
+ for (const [key, val] of Object.entries(node)) {
25
+ if (key === "$ref" && typeof val === "string") {
26
+ refs.add(val);
27
+ }
28
+ else {
29
+ collectRefs(val, refs);
30
+ }
31
+ }
32
+ }
33
+ // Extract schema name from a $ref like "#/components/schemas/Foo"
34
+ function schemaNameFromRef(ref) {
35
+ const prefix = "#/components/schemas/";
36
+ return ref.startsWith(prefix) ? ref.slice(prefix.length) : null;
37
+ }
38
+ // Collect initial refs from paths (not from components to avoid circular bootstrapping)
39
+ const pathRefs = new Set();
40
+ collectRefs(spec.paths, pathRefs);
41
+ // BFS to transitively follow refs within referenced schemas
42
+ const referenced = new Set();
43
+ const queue = [];
44
+ for (const ref of pathRefs) {
45
+ const name = schemaNameFromRef(ref);
46
+ if (name && schemas[name] && !referenced.has(name)) {
47
+ referenced.add(name);
48
+ queue.push(name);
49
+ }
50
+ }
51
+ while (queue.length > 0) {
52
+ const name = queue.pop();
53
+ const inner = new Set();
54
+ collectRefs(schemas[name], inner);
55
+ for (const ref of inner) {
56
+ const refName = schemaNameFromRef(ref);
57
+ if (refName && schemas[refName] && !referenced.has(refName)) {
58
+ referenced.add(refName);
59
+ queue.push(refName);
60
+ }
61
+ }
62
+ }
63
+ // Build cleaned spec — shallow clone, then rebuild components/schemas with only referenced entries
64
+ const cleaned = { ...spec };
65
+ cleaned.components = { ...spec.components };
66
+ if (referenced.size === 0) {
67
+ delete cleaned.components.schemas;
68
+ }
69
+ else {
70
+ cleaned.components.schemas = {};
71
+ for (const name of referenced) {
72
+ cleaned.components.schemas[name] = schemas[name];
73
+ }
74
+ }
75
+ if (Object.keys(cleaned.components).length === 0) {
76
+ delete cleaned.components;
77
+ }
78
+ return cleaned;
79
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Suspend or unsuspend a user.
3
+ * No-op when the adapter does not implement setSuspended.
4
+ */
5
+ export declare function setSuspended(userId: string, suspended: boolean, reason?: string): Promise<void>;
6
+ /**
7
+ * Get the suspension status of a user.
8
+ * Returns { suspended: false } when the adapter does not implement getSuspended.
9
+ */
10
+ export declare function getSuspended(userId: string): Promise<{
11
+ suspended: boolean;
12
+ suspendedReason?: string;
13
+ }>;
@@ -0,0 +1,23 @@
1
+ import { getAuthAdapter } from "./authAdapter";
2
+ /**
3
+ * Suspend or unsuspend a user.
4
+ * No-op when the adapter does not implement setSuspended.
5
+ */
6
+ export async function setSuspended(userId, suspended, reason) {
7
+ const adapter = getAuthAdapter();
8
+ if (adapter.setSuspended) {
9
+ await adapter.setSuspended(userId, suspended, reason);
10
+ }
11
+ }
12
+ /**
13
+ * Get the suspension status of a user.
14
+ * Returns { suspended: false } when the adapter does not implement getSuspended.
15
+ */
16
+ export async function getSuspended(userId) {
17
+ const adapter = getAuthAdapter();
18
+ if (adapter.getSuspended) {
19
+ const result = await adapter.getSuspended(userId);
20
+ return result ?? { suspended: false };
21
+ }
22
+ return { suspended: false };
23
+ }
@@ -28,7 +28,7 @@ export const createTenant = async (tenantId, options) => {
28
28
  }
29
29
  if (existing && existing.deletedAt) {
30
30
  // Reactivate soft-deleted tenant
31
- await Tenant.findOneAndUpdate({ tenantId }, { deletedAt: null, displayName: options?.displayName, config: options?.config });
31
+ await Tenant.findOneAndUpdate({ tenantId }, { $set: { deletedAt: null, displayName: options?.displayName, config: options?.config } });
32
32
  return;
33
33
  }
34
34
  await Tenant.create({
@@ -40,7 +40,7 @@ export const createTenant = async (tenantId, options) => {
40
40
  export const deleteTenant = async (tenantId) => {
41
41
  const { invalidateTenantCache } = await import("../middleware/tenant");
42
42
  // Soft-delete
43
- await Tenant.findOneAndUpdate({ tenantId }, { deletedAt: new Date() });
43
+ await Tenant.findOneAndUpdate({ tenantId }, { $set: { deletedAt: new Date() } });
44
44
  invalidateTenantCache(tenantId);
45
45
  };
46
46
  export const getTenant = async (tenantId) => {
@@ -0,0 +1,39 @@
1
+ import type { Context } from "hono";
2
+ import type { AppEnv } from "./context";
3
+ import type { StorageAdapter, UploadResult } from "./storageAdapter";
4
+ export interface UploadOpts {
5
+ field?: string | string[];
6
+ maxFileSize?: number;
7
+ maxFiles?: number;
8
+ allowedMimeTypes?: string[];
9
+ keyPrefix?: string;
10
+ generateKey?: (file: File, ctx: {
11
+ userId?: string;
12
+ tenantId?: string;
13
+ }) => string;
14
+ tenantScopedKeys?: boolean;
15
+ }
16
+ export declare const setStorageAdapter: (adapter: StorageAdapter) => void;
17
+ export declare const getStorageAdapter: () => StorageAdapter | null;
18
+ export declare const setUploadConfig: (config: UploadOpts) => void;
19
+ export declare const getUploadConfig: () => UploadOpts;
20
+ export declare const generateUploadKey: (file: File, ctx: {
21
+ userId?: string;
22
+ tenantId?: string;
23
+ }, opts?: UploadOpts) => string;
24
+ export declare const generateUploadKeyFromFilename: (filename: string | undefined, ctx: {
25
+ userId?: string;
26
+ tenantId?: string;
27
+ }, opts?: UploadOpts) => string;
28
+ export declare const validateFile: (file: File, opts: {
29
+ maxFileSize?: number;
30
+ allowedMimeTypes?: string[];
31
+ }) => string | null;
32
+ export declare const processUpload: (file: File, opts: UploadOpts & {
33
+ ctx?: {
34
+ userId?: string;
35
+ tenantId?: string;
36
+ };
37
+ bucket?: string;
38
+ }) => Promise<UploadResult>;
39
+ export declare const parseUpload: (c: Context<AppEnv>, opts?: UploadOpts) => Promise<UploadResult[]>;