@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
package/dist/app.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import { cors } from "hono/cors";
3
- import { logger } from "hono/logger";
4
3
  import { secureHeaders } from "hono/secure-headers";
5
4
  import { Scalar } from "@scalar/hono-api-reference";
6
- import { HttpError } from "./lib/HttpError";
5
+ import { HttpError, ValidationError } from "./lib/HttpError";
7
6
  import { rateLimit } from "./middleware/rateLimit";
8
7
  import { bearerAuth } from "./middleware/bearerAuth";
9
8
  import { identify } from "./middleware/identify";
10
- import { HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN, HEADER_CSRF_TOKEN } from "./lib/constants";
11
- import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setPasswordResetConfig, setPasswordPolicy, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive, setRefreshTokenConfig, setMfaConfig, setCsrfEnabled } from "./lib/appConfig";
9
+ import { defaultValidationErrorFormatter } from "./lib/context";
10
+ import { HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN, HEADER_CSRF_TOKEN, HEADER_REQUEST_ID } from "./lib/constants";
11
+ import { requestId } from "./middleware/requestId";
12
+ import { requestLogger } from "./middleware/requestLogger";
13
+ import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setPasswordResetConfig, setPasswordPolicy, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive, setRefreshTokenConfig, setMfaConfig, setCsrfEnabled, setSigningConfig, setJwtConfig, setCheckSuspensionOnIdentify, setBreachedPasswordConfig, setCaptchaConfig, setStepUpConfig, setM2MConfig, setOidcConfig, setSamlConfig, setScimConfig } from "./lib/appConfig";
12
14
  import { setEmailVerificationStore } from "./lib/emailVerification";
13
15
  import { setPasswordResetStore } from "./lib/resetPassword";
14
16
  import { setAuthRateLimitStore } from "./lib/authRateLimit";
@@ -22,10 +24,18 @@ import { connectRedis } from "./lib/redis";
22
24
  import { setSessionStore } from "./lib/session";
23
25
  import { setCacheStore } from "./middleware/cacheResponse";
24
26
  import { maybeAutoRegister } from "./lib/createRoute";
27
+ import { setStorageAdapter, setUploadConfig } from "./lib/upload";
28
+ import { validateJwtSecrets } from "./lib/jwt";
25
29
  export const createApp = async (config) => {
26
30
  const { routesDir, app: appConfig = {}, auth: authConfig = {}, security: securityConfig = {}, middleware = [], db = {}, } = config;
31
+ if (config.securityEvents) {
32
+ const { setSecurityEventConfig } = await import("./lib/securityEvents");
33
+ setSecurityEventConfig(config.securityEvents);
34
+ }
27
35
  const appName = appConfig.name ?? "Bun Core API";
28
36
  const openApiVersion = appConfig.version ?? "1.0.0";
37
+ // Validate JWT secrets eagerly so misconfiguration is caught at startup
38
+ validateJwtSecrets();
29
39
  // Trust-proxy for IP extraction
30
40
  const { setTrustProxy } = await import("./lib/clientIp");
31
41
  setTrustProxy(securityConfig.trustProxy ?? false);
@@ -33,6 +43,13 @@ export const createApp = async (config) => {
33
43
  if (corsOrigins === "*" && process.env.NODE_ENV === "production") {
34
44
  console.warn("[security] CORS is set to wildcard (*) in production. Configure security.cors with specific origins to restrict cross-origin access.");
35
45
  }
46
+ if (securityConfig.csrf?.enabled && corsOrigins === "*") {
47
+ if (process.env.NODE_ENV === "production") {
48
+ throw new Error("[security] CSRF protection with wildcard CORS (*) is unsafe. " +
49
+ "Set security.cors to specific origins when using CSRF.");
50
+ }
51
+ console.warn("[security] CSRF is enabled with wildcard CORS. This will be rejected in production.");
52
+ }
36
53
  const rlConfig = securityConfig.rateLimit ?? { windowMs: 60_000, max: 100 };
37
54
  const botCfg = securityConfig.botProtection ?? {};
38
55
  const enableBearerAuth = securityConfig.bearerAuth !== false;
@@ -136,16 +153,76 @@ export const createApp = async (config) => {
136
153
  setPasswordResetConfig(passwordReset ?? null);
137
154
  setPasswordPolicy(authConfig.passwordPolicy ?? {});
138
155
  setPasswordResetStore(sessions);
156
+ const { setDeletionCancelTokenStore } = await import("./lib/deletionCancelToken");
157
+ setDeletionCancelTokenStore(sessions);
139
158
  setAuthRateLimitStore(authRateLimit?.store ?? (enableRedis ? "redis" : "memory"));
159
+ if (authRateLimit?.credentialStuffing) {
160
+ const { setCredentialStuffingConfig } = await import("./lib/credentialStuffing");
161
+ setCredentialStuffingConfig(authRateLimit.credentialStuffing);
162
+ }
140
163
  setMaxSessions(sessionPolicy.maxSessions ?? 6);
141
164
  setPersistSessionMetadata(sessionPolicy.persistSessionMetadata ?? true);
142
165
  setIncludeInactiveSessions(sessionPolicy.includeInactiveSessions ?? false);
143
166
  setTrackLastActive(sessionPolicy.trackLastActive ?? false);
144
167
  setRefreshTokenConfig(authConfig.refreshTokens ?? null);
145
168
  setMfaConfig(authConfig.mfa ?? null);
169
+ if (authConfig.jwt)
170
+ setJwtConfig(authConfig.jwt);
171
+ if (authConfig.checkSuspensionOnIdentify)
172
+ setCheckSuspensionOnIdentify(true);
173
+ if (authConfig.breachedPasswordCheck)
174
+ setBreachedPasswordConfig(authConfig.breachedPasswordCheck);
175
+ if (authConfig.stepUp)
176
+ setStepUpConfig(authConfig.stepUp);
177
+ // JWT config
178
+ if (authConfig.jwt)
179
+ setJwtConfig(authConfig.jwt);
180
+ // OIDC: load keys, set RS256, mount discovery routes
181
+ if (authConfig.oidc) {
182
+ setOidcConfig(authConfig.oidc);
183
+ // Override JWT config with OIDC issuer and RS256
184
+ setJwtConfig({ ...(authConfig.jwt ?? {}), issuer: authConfig.oidc.issuer, algorithm: "RS256" });
185
+ const { loadJwksKey, generateAndLoadKeyPair, loadPreviousKey } = await import("./lib/jwks");
186
+ const { _setAlgorithm } = await import("./lib/jwt");
187
+ if (authConfig.oidc.signingKey) {
188
+ await loadJwksKey(authConfig.oidc.signingKey);
189
+ }
190
+ else {
191
+ await generateAndLoadKeyPair();
192
+ }
193
+ for (const prev of authConfig.oidc.previousKeys ?? []) {
194
+ await loadPreviousKey(prev);
195
+ }
196
+ _setAlgorithm("RS256");
197
+ }
146
198
  if (oauthProviders)
147
199
  initOAuthProviders(oauthProviders);
148
200
  const configuredOAuth = getConfiguredOAuthProviders();
201
+ // Start the account deletion worker when queued deletion is configured.
202
+ // The worker runs in-process alongside the API server.
203
+ if (authConfig.accountDeletion?.queued && enableAuthRoutes) {
204
+ try {
205
+ const { createWorker } = await import("./lib/queue");
206
+ const appName_ = appName;
207
+ const accountDeletion_ = authConfig.accountDeletion;
208
+ createWorker(`${appName_}:account-deletions`, async (job) => {
209
+ const { userId } = job.data;
210
+ const adapter_ = authAdapter;
211
+ if (accountDeletion_.onBeforeDelete)
212
+ await accountDeletion_.onBeforeDelete(userId);
213
+ if (adapter_.deleteUser)
214
+ await adapter_.deleteUser(userId);
215
+ if (accountDeletion_.onAfterDelete)
216
+ await accountDeletion_.onAfterDelete(userId);
217
+ }, { concurrency: 1 });
218
+ }
219
+ catch (err) {
220
+ if (err?.message?.includes("bullmq is not installed")) {
221
+ throw new Error("createApp: accountDeletion.queued requires BullMQ. Run: bun add bullmq");
222
+ }
223
+ throw err;
224
+ }
225
+ }
149
226
  // OAuth paths must bypass bearer auth — initiation and link routes are browser redirects,
150
227
  // callbacks come from external providers; none can send a bearer token header.
151
228
  const oauthBypass = configuredOAuth.flatMap((p) => [
@@ -153,10 +230,44 @@ export const createApp = async (config) => {
153
230
  `/auth/${p}/callback`,
154
231
  `/auth/${p}/link`,
155
232
  ]);
156
- const DEFAULT_BYPASS = ["/docs", "/openapi.json", "/sw.js", "/health", "/"];
157
- const bearerAuthBypass = [...DEFAULT_BYPASS, ...oauthBypass, ...extraBypass];
233
+ const DEFAULT_BYPASS = ["/docs", "/openapi.json", "/sw.js", "/health", "/", "/metrics", "/oauth/token", "/.well-known/openid-configuration", "/.well-known/jwks.json", "/auth/saml/*", "/scim/v2/*"];
234
+ // Add per-version docs/spec paths when versioning is configured
235
+ const versionBypass = config.versioning
236
+ ? config.versioning.versions.flatMap((v) => [`/${v}/docs`, `/${v}/openapi.json`])
237
+ : [];
238
+ const bearerAuthBypass = [...DEFAULT_BYPASS, ...versionBypass, ...oauthBypass, ...extraBypass];
158
239
  const app = new OpenAPIHono();
159
- app.use(logger());
240
+ app.use(requestId);
241
+ // Set the validation error formatter on context so defaultHook and onError both pick it up
242
+ const validationFormatter = config.validation?.formatError ?? defaultValidationErrorFormatter;
243
+ app.use("*", async (c, next) => {
244
+ c.set("validationErrorFormatter", validationFormatter);
245
+ await next();
246
+ });
247
+ // Metrics collection middleware (before requestLogger so it captures all requests)
248
+ if (config.metrics?.enabled) {
249
+ const metricsAuth = config.metrics.auth ?? "none";
250
+ if (metricsAuth === "none" && !config.metrics.unsafePublic) {
251
+ if (process.env.NODE_ENV === "production") {
252
+ throw new Error("[security] metrics.auth is required in production. Set metrics.auth or explicitly set unsafePublic: true with auth: \"none\".");
253
+ }
254
+ console.warn("[security] /metrics is enabled without auth. Configure metrics.auth for production.");
255
+ }
256
+ const { metricsCollector } = await import("./middleware/metrics");
257
+ app.use(metricsCollector({
258
+ excludePaths: config.metrics.excludePaths,
259
+ normalizePath: config.metrics.normalizePath,
260
+ }));
261
+ }
262
+ const loggingConfig = config.logging ?? {};
263
+ if (loggingConfig.enabled !== false) {
264
+ app.use(requestLogger({
265
+ onLog: loggingConfig.onLog,
266
+ level: loggingConfig.level,
267
+ excludePaths: loggingConfig.excludePaths,
268
+ excludeMethods: loggingConfig.excludeMethods,
269
+ }));
270
+ }
160
271
  const headerOpts = {};
161
272
  if (securityConfig.headers?.contentSecurityPolicy) {
162
273
  headerOpts["Content-Security-Policy"] = securityConfig.headers.contentSecurityPolicy;
@@ -176,7 +287,7 @@ export const createApp = async (config) => {
176
287
  const corsAllowHeaders = ["Content-Type", "Authorization", HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN];
177
288
  if (securityConfig.csrf?.enabled)
178
289
  corsAllowHeaders.push(HEADER_CSRF_TOKEN);
179
- app.use(cors({ origin: corsOrigins, allowHeaders: corsAllowHeaders, exposeHeaders: ["x-cache"], credentials: true }));
290
+ app.use(cors({ origin: corsOrigins, allowHeaders: corsAllowHeaders, exposeHeaders: ["x-cache", HEADER_REQUEST_ID], credentials: true }));
180
291
  if ((botCfg.blockList?.length ?? 0) > 0) {
181
292
  const { botProtection } = await import("./middleware/botProtection");
182
293
  app.use(botProtection({ blockList: botCfg.blockList }));
@@ -185,13 +296,22 @@ export const createApp = async (config) => {
185
296
  if (enableBearerAuth) {
186
297
  app.use(async (c, next) => {
187
298
  const path = c.req.path;
188
- if (bearerAuthBypass.includes(path)) {
299
+ const bypassed = bearerAuthBypass.some((entry) => entry.endsWith("*") ? path.startsWith(entry.slice(0, -1)) : path === entry);
300
+ if (bypassed) {
189
301
  return next();
190
302
  }
191
303
  return bearerAuth(c, next);
192
304
  });
193
305
  }
194
306
  app.use(identify);
307
+ // Signing config — make available to pagination, identify, and other lib modules
308
+ if (securityConfig.signing) {
309
+ setSigningConfig(securityConfig.signing);
310
+ }
311
+ // CAPTCHA config — store globally so requireCaptcha() can read it without explicit param
312
+ if (securityConfig.captcha) {
313
+ setCaptchaConfig(securityConfig.captcha);
314
+ }
195
315
  // CSRF protection (after identify so we can check for auth cookie presence)
196
316
  if (securityConfig.csrf?.enabled) {
197
317
  setCsrfEnabled(true);
@@ -224,6 +344,10 @@ export const createApp = async (config) => {
224
344
  }
225
345
  for (const mw of middleware)
226
346
  app.use(mw);
347
+ if (authConfig.mfa?.required) {
348
+ const { requireMfaSetup } = await import("./middleware/requireMfaSetup");
349
+ app.use(requireMfaSetup);
350
+ }
227
351
  setAppName(appName);
228
352
  // Schema pre-loading — import shared schema files before routes so registerSchema /
229
353
  // registerSchemas calls run first, guaranteeing $ref instead of inline shapes.
@@ -273,13 +397,15 @@ export const createApp = async (config) => {
273
397
  continue; // mounted separately below when mfa is configured
274
398
  if (file === "jobs.ts")
275
399
  continue; // mounted separately below when jobs.statusEndpoint is true
400
+ if (file === "oidc.ts")
401
+ continue; // mounted separately below when oidc is configured
276
402
  const mod = await import(`${coreRoutesDir}/${file}`);
277
403
  if (mod.router)
278
404
  app.route("/", mod.router);
279
405
  }
280
406
  if (enableAuthRoutes) {
281
407
  const { createAuthRouter } = await import(`${coreRoutesDir}/auth`);
282
- app.route("/", createAuthRouter({ primaryField, emailVerification, passwordReset, rateLimit: authRateLimit, accountDeletion: authConfig.accountDeletion, refreshTokens: authConfig.refreshTokens }));
408
+ app.route("/", createAuthRouter({ primaryField, emailVerification, passwordReset, rateLimit: authRateLimit, accountDeletion: authConfig.accountDeletion, refreshTokens: authConfig.refreshTokens, stepUp: authConfig.stepUp }));
283
409
  }
284
410
  if (configuredOAuth.length > 0) {
285
411
  const { createOAuthRouter } = await import(`${coreRoutesDir}/oauth`);
@@ -295,53 +421,231 @@ export const createApp = async (config) => {
295
421
  const { createMfaRouter } = await import(`${coreRoutesDir}/mfa`);
296
422
  app.route("/", createMfaRouter({ rateLimit: authRateLimit }));
297
423
  }
424
+ if (authConfig.mfa?.webauthn?.allowPasswordlessLogin && enableAuthRoutes) {
425
+ const { assertWebAuthnDependency } = await import("./services/mfa");
426
+ await assertWebAuthnDependency();
427
+ const { createPasskeyRouter } = await import(`${coreRoutesDir}/passkey`);
428
+ app.route("/", createPasskeyRouter());
429
+ }
430
+ if (authConfig.m2m?.enabled !== false && authConfig.m2m) {
431
+ setM2MConfig(authConfig.m2m);
432
+ const { createM2MRouter } = await import(`${coreRoutesDir}/m2m`);
433
+ app.route("/", createM2MRouter());
434
+ }
435
+ if (config.jobs?.statusEndpoint) {
436
+ const jobsAuth = config.jobs.auth ?? "none";
437
+ if (jobsAuth === "none" && !config.jobs.unsafePublic) {
438
+ if (process.env.NODE_ENV === "production") {
439
+ throw new Error("[security] jobs.auth is required in production. Set jobs.auth or explicitly set unsafePublic: true with auth: \"none\".");
440
+ }
441
+ console.warn("[security] /jobs is enabled without auth. Configure jobs.auth for production.");
442
+ }
443
+ }
298
444
  if (config.jobs?.statusEndpoint) {
299
445
  const { createJobsRouter } = await import(`${coreRoutesDir}/jobs`);
300
446
  app.route("/", createJobsRouter(config.jobs));
301
447
  }
302
- // Service routes — collect all, sort by optional exported `priority`, then mount
303
- const serviceGlob = new Bun.Glob("**/*.ts");
304
- const serviceFiles = [];
305
- for await (const file of serviceGlob.scan({ cwd: routesDir })) {
306
- serviceFiles.push(file);
307
- }
308
- const serviceMods = await Promise.all(serviceFiles.map(async (file) => ({
309
- file,
310
- mod: await import(`${routesDir}/${file}`),
311
- })));
312
- serviceMods
313
- .sort((a, b) => (a.mod.priority ?? Infinity) - (b.mod.priority ?? Infinity))
314
- .forEach(({ mod }) => {
315
- if (mod.router)
316
- app.route("/", mod.router);
317
- });
448
+ if (config.metrics?.enabled) {
449
+ const { createMetricsRouter } = await import(`${coreRoutesDir}/metrics`);
450
+ app.route("/", createMetricsRouter({
451
+ auth: config.metrics.auth,
452
+ queues: config.metrics.queues,
453
+ unsafePublic: config.metrics.unsafePublic,
454
+ }));
455
+ }
456
+ if (config.groups?.managementRoutes) {
457
+ const { createGroupsRouter } = await import(`${coreRoutesDir}/groups`);
458
+ app.route("/", createGroupsRouter(config.groups));
459
+ }
460
+ if (authConfig.oidc) {
461
+ const { createOidcRouter } = await import(`${coreRoutesDir}/oidc`);
462
+ app.route("/", createOidcRouter());
463
+ }
464
+ if (authConfig.saml) {
465
+ setSamlConfig(authConfig.saml);
466
+ const { createSamlRouter } = await import(`${coreRoutesDir}/saml`);
467
+ app.route("/", createSamlRouter());
468
+ }
469
+ if (authConfig.scim) {
470
+ const { setScimTokens } = await import("./middleware/scimAuth");
471
+ setScimConfig(authConfig.scim);
472
+ setScimTokens(authConfig.scim.bearerTokens);
473
+ const { createScimRouter } = await import(`${coreRoutesDir}/scim`);
474
+ app.route("/", createScimRouter());
475
+ }
476
+ if (config.upload) {
477
+ const { storage, presignedUrls, authorization, allowExternalKeys, ...uploadOpts } = config.upload;
478
+ setStorageAdapter(storage);
479
+ setUploadConfig(uploadOpts);
480
+ // Wire upload registry store to match session store backend
481
+ const { setUploadRegistryStore } = await import("./lib/uploadRegistry");
482
+ setUploadRegistryStore(sessions);
483
+ if (presignedUrls) {
484
+ const { createUploadsRouter } = await import(`${coreRoutesDir}/uploads`);
485
+ const presignConfig = presignedUrls === true ? {} : presignedUrls;
486
+ app.route("/", createUploadsRouter({
487
+ ...presignConfig,
488
+ authorization,
489
+ allowExternalKeys,
490
+ }));
491
+ }
492
+ }
493
+ // Helper to register standard security schemes on an OpenAPI registry
494
+ const registerSecuritySchemes = (registry) => {
495
+ registry.registerComponent("securitySchemes", "cookieAuth", {
496
+ type: "apiKey",
497
+ in: "cookie",
498
+ name: "token",
499
+ description: "Session cookie set automatically on login/register.",
500
+ });
501
+ registry.registerComponent("securitySchemes", "userToken", {
502
+ type: "apiKey",
503
+ in: "header",
504
+ name: "x-user-token",
505
+ description: "JWT session token passed as the x-user-token request header (alternative to the session cookie).",
506
+ });
507
+ registry.registerComponent("securitySchemes", "bearerAuth", {
508
+ type: "http",
509
+ scheme: "bearer",
510
+ description: "API key passed as Authorization: Bearer <token>. Required on all endpoints unless bearer auth is disabled in CreateAppConfig or the path is in the bypass list.",
511
+ });
512
+ };
513
+ if (config.versioning) {
514
+ // Version-aware route discovery — each version gets its own OpenAPIHono instance
515
+ const { versions, sharedDir = "shared", defaultVersion = versions[versions.length - 1] } = config.versioning;
516
+ const { setVersionPrefix, clearVersionPrefix, getVersionToken, drainCapturedTokens, assertCapturedTokens } = await import("./lib/createRoute");
517
+ const { defaultHook } = await import("./lib/context");
518
+ const { stripUnreferencedSchemas } = await import("./lib/stripUnreferencedSchemas");
519
+ // Import shared routes with no prefix — schemas stay unprefixed (version-agnostic)
520
+ let sharedMods = [];
521
+ if (sharedDir !== false) {
522
+ const sharedRoutesDir = `${routesDir}/${sharedDir}`;
523
+ try {
524
+ const sharedGlob = new Bun.Glob("**/*.ts");
525
+ const sharedFiles = [];
526
+ for await (const file of sharedGlob.scan({ cwd: sharedRoutesDir })) {
527
+ sharedFiles.push(file);
528
+ }
529
+ sharedMods = await Promise.all(sharedFiles.map(async (file) => ({ file, mod: await import(`${sharedRoutesDir}/${file}`) })));
530
+ }
531
+ catch {
532
+ // sharedDir doesn't exist — fine
533
+ }
534
+ }
535
+ // Drain any tokens captured during shared route imports (token=null, correct since no prefix was set)
536
+ // to prevent null tokens from bleeding into per-version assertions below.
537
+ drainCapturedTokens();
538
+ // For each version sequentially: set prefix, import routes, mount on isolated OpenAPIHono
539
+ for (const version of versions) {
540
+ setVersionPrefix(version);
541
+ const expectedToken = getVersionToken();
542
+ const vApp = new OpenAPIHono({ defaultHook });
543
+ const versionRoutesDir = `${routesDir}/${version}`;
544
+ const versionFiles = [];
545
+ try {
546
+ const versionGlob = new Bun.Glob("**/*.ts");
547
+ for await (const file of versionGlob.scan({ cwd: versionRoutesDir })) {
548
+ versionFiles.push(file);
549
+ }
550
+ }
551
+ catch {
552
+ // version dir doesn't exist — fine
553
+ }
554
+ // Import all version route files in parallel
555
+ const versionMods = await Promise.all(versionFiles.map(async (file) => ({ file, mod: await import(`${versionRoutesDir}/${file}`) })));
556
+ // Assert version token to catch top-level await interleaving bugs at startup
557
+ assertCapturedTokens(drainCapturedTokens(), expectedToken);
558
+ // Mount version-specific routes (sorted by priority)
559
+ versionMods
560
+ .sort((a, b) => (a.mod.priority ?? Infinity) - (b.mod.priority ?? Infinity))
561
+ .forEach(({ mod }) => {
562
+ if (mod.router)
563
+ vApp.route("/", mod.router);
564
+ });
565
+ // Mount shared routes on this versioned app
566
+ for (const { mod } of sharedMods) {
567
+ if (mod.router)
568
+ vApp.route("/", mod.router);
569
+ }
570
+ registerSecuritySchemes(vApp.openAPIRegistry);
571
+ // Serve per-version spec stripped of schemas from other versions
572
+ vApp.get("/openapi.json", (c) => {
573
+ const spec = vApp.getOpenAPIDocument({
574
+ openapi: "3.0.0",
575
+ info: { title: `${appName} ${version.toUpperCase()}`, version: openApiVersion },
576
+ });
577
+ return c.json(stripUnreferencedSchemas(spec));
578
+ });
579
+ // Per-version Scalar docs
580
+ vApp.get("/docs", Scalar({ url: `/${version}/openapi.json` }));
581
+ clearVersionPrefix();
582
+ // Mount versioned app under /v1, /v2, etc.
583
+ app.route(`/${version}`, vApp);
584
+ }
585
+ // Root /docs → version selector page
586
+ app.get("/docs", (c) => {
587
+ const links = versions
588
+ .map((v) => `<li><a href="/${v}/docs" style="font-size:1.1em">${v.toUpperCase()}</a></li>`)
589
+ .join("\n");
590
+ const html = `<!DOCTYPE html>
591
+ <html lang="en">
592
+ <head><meta charset="utf-8"><title>${appName} — API Docs</title>
593
+ <style>body{font-family:sans-serif;padding:2rem}ul{list-style:none;padding:0}li{margin:.5rem 0}</style>
594
+ </head>
595
+ <body>
596
+ <h1>${appName}</h1>
597
+ <h2>API Documentation</h2>
598
+ <ul>${links}</ul>
599
+ </body></html>`;
600
+ return c.html(html);
601
+ });
602
+ // Root /openapi.json → 302 to default version (no merged spec exists)
603
+ app.get("/openapi.json", (c) => c.redirect(`/${defaultVersion}/openapi.json`, 302));
604
+ }
605
+ else {
606
+ // Non-versioned path — existing behavior unchanged
607
+ // Service routes — collect all, sort by optional exported `priority`, then mount
608
+ const serviceGlob = new Bun.Glob("**/*.ts");
609
+ const serviceFiles = [];
610
+ for await (const file of serviceGlob.scan({ cwd: routesDir })) {
611
+ serviceFiles.push(file);
612
+ }
613
+ const serviceMods = await Promise.all(serviceFiles.map(async (file) => ({
614
+ file,
615
+ mod: await import(`${routesDir}/${file}`),
616
+ })));
617
+ serviceMods
618
+ .sort((a, b) => (a.mod.priority ?? Infinity) - (b.mod.priority ?? Infinity))
619
+ .forEach(({ mod }) => {
620
+ if (mod.router)
621
+ app.route("/", mod.router);
622
+ });
623
+ registerSecuritySchemes(app.openAPIRegistry);
624
+ app.doc("/openapi.json", { openapi: "3.0.0", info: { title: appName, version: openApiVersion } });
625
+ app.get("/docs", Scalar({ url: "/openapi.json" }));
626
+ }
318
627
  app.onError((err, c) => {
628
+ const reqId = c.get("requestId") ?? "unknown";
629
+ // ValidationError extends HttpError — must check first or the details payload is lost
630
+ if (err instanceof ValidationError) {
631
+ const fmt = c.get("validationErrorFormatter") ?? defaultValidationErrorFormatter;
632
+ try {
633
+ return c.json(fmt(err.issues, reqId), 400);
634
+ }
635
+ catch {
636
+ return c.json(defaultValidationErrorFormatter(err.issues, reqId), 400);
637
+ }
638
+ }
319
639
  if (err instanceof HttpError) {
320
- return c.json({ error: err.message }, err.status);
640
+ const body = { error: err.message, requestId: reqId };
641
+ if (err.code !== undefined)
642
+ body.code = err.code;
643
+ return c.json(body, err.status);
321
644
  }
322
645
  console.error(err);
323
- return c.json({ error: "Internal Server Error" }, 500);
324
- });
325
- app.notFound((c) => c.json({ error: "Not Found" }, 404));
326
- app.openAPIRegistry.registerComponent("securitySchemes", "cookieAuth", {
327
- type: "apiKey",
328
- in: "cookie",
329
- name: "token",
330
- description: "Session cookie set automatically on login/register.",
331
- });
332
- app.openAPIRegistry.registerComponent("securitySchemes", "userToken", {
333
- type: "apiKey",
334
- in: "header",
335
- name: "x-user-token",
336
- description: "JWT session token passed as the x-user-token request header (alternative to the session cookie).",
337
- });
338
- app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
339
- type: "http",
340
- scheme: "bearer",
341
- description: "API key passed as Authorization: Bearer <token>. Required on all endpoints unless bearer auth is disabled in CreateAppConfig or the path is in the bypass list.",
646
+ return c.json({ error: "Internal Server Error", requestId: reqId }, 500);
342
647
  });
343
- app.doc("/openapi.json", { openapi: "3.0.0", info: { title: appName, version: openApiVersion } });
344
- app.get("/docs", Scalar({ url: "/openapi.json" }));
648
+ app.notFound((c) => c.json({ error: "Not Found", requestId: c.get("requestId") ?? "unknown" }, 404));
345
649
  app.get("/sw.js", (c) => c.body("", 200, { "Content-Type": "application/javascript" }));
346
650
  return app;
347
651
  };