@rebasepro/server-core 0.2.3 → 0.3.0

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 (109) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +12 -0
  2. package/dist/common/src/collections/index.d.ts +1 -0
  3. package/dist/common/src/util/permissions.d.ts +1 -0
  4. package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
  5. package/dist/index-Cr1D21av.js.map +1 -0
  6. package/dist/index.es.js +2166 -208
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +2155 -193
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/server-core/src/api/logs-routes.d.ts +37 -0
  11. package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
  12. package/dist/server-core/src/api/types.d.ts +6 -1
  13. package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
  14. package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
  15. package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
  16. package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
  17. package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
  18. package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
  19. package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
  20. package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
  21. package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
  22. package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
  23. package/dist/server-core/src/auth/index.d.ts +5 -3
  24. package/dist/server-core/src/auth/interfaces.d.ts +93 -3
  25. package/dist/server-core/src/auth/jwt.d.ts +3 -1
  26. package/dist/server-core/src/auth/mfa.d.ts +49 -0
  27. package/dist/server-core/src/auth/middleware.d.ts +7 -0
  28. package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
  29. package/dist/server-core/src/auth/routes.d.ts +3 -3
  30. package/dist/server-core/src/env.d.ts +6 -0
  31. package/dist/server-core/src/index.d.ts +1 -0
  32. package/dist/server-core/src/init.d.ts +3 -3
  33. package/dist/server-core/src/services/webhook-service.d.ts +29 -0
  34. package/dist/server-core/src/storage/image-transform.d.ts +48 -0
  35. package/dist/server-core/src/storage/index.d.ts +3 -0
  36. package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
  37. package/dist/types/src/controllers/auth.d.ts +2 -24
  38. package/dist/types/src/controllers/client.d.ts +0 -3
  39. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  40. package/dist/types/src/controllers/data_driver.d.ts +18 -0
  41. package/dist/types/src/controllers/registry.d.ts +5 -4
  42. package/dist/types/src/rebase_context.d.ts +1 -1
  43. package/dist/types/src/types/auth_adapter.d.ts +2 -4
  44. package/dist/types/src/types/collections.d.ts +0 -4
  45. package/dist/types/src/types/component_ref.d.ts +1 -1
  46. package/dist/types/src/types/cron.d.ts +1 -1
  47. package/dist/types/src/types/entity_views.d.ts +1 -0
  48. package/dist/types/src/types/export_import.d.ts +1 -1
  49. package/dist/types/src/types/formex.d.ts +2 -2
  50. package/dist/types/src/types/properties.d.ts +2 -2
  51. package/dist/types/src/types/translations.d.ts +28 -12
  52. package/dist/types/src/types/user_management_delegate.d.ts +6 -4
  53. package/dist/types/src/users/roles.d.ts +0 -8
  54. package/package.json +8 -6
  55. package/src/api/ast-schema-editor.ts +4 -4
  56. package/src/api/errors.ts +16 -7
  57. package/src/api/logs-routes.ts +129 -0
  58. package/src/api/rest/api-generator.ts +42 -2
  59. package/src/api/rest/query-parser.ts +37 -1
  60. package/src/api/types.ts +6 -1
  61. package/src/auth/adapter-middleware.ts +20 -4
  62. package/src/auth/admin-routes.ts +39 -14
  63. package/src/auth/api-keys/api-key-middleware.ts +126 -0
  64. package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
  65. package/src/auth/api-keys/api-key-routes.ts +183 -0
  66. package/src/auth/api-keys/api-key-store.ts +317 -0
  67. package/src/auth/api-keys/api-key-types.ts +94 -0
  68. package/src/auth/api-keys/index.ts +37 -0
  69. package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
  70. package/src/auth/builtin-auth-adapter.ts +31 -19
  71. package/src/auth/index.ts +7 -3
  72. package/src/auth/interfaces.ts +111 -3
  73. package/src/auth/jwt.ts +19 -5
  74. package/src/auth/mfa.ts +160 -0
  75. package/src/auth/middleware.ts +20 -1
  76. package/src/auth/rate-limiter.ts +92 -0
  77. package/src/auth/routes.ts +455 -24
  78. package/src/cron/cron-loader.ts +5 -10
  79. package/src/cron/cron-scheduler.ts +11 -12
  80. package/src/cron/cron-store.ts +8 -7
  81. package/src/env.ts +2 -0
  82. package/src/functions/function-loader.ts +6 -9
  83. package/src/index.ts +1 -2
  84. package/src/init.ts +37 -7
  85. package/src/serve-spa.ts +5 -4
  86. package/src/services/webhook-service.ts +155 -0
  87. package/src/storage/image-transform.ts +202 -0
  88. package/src/storage/index.ts +3 -0
  89. package/src/storage/routes.ts +56 -3
  90. package/src/storage/tus-handler.ts +315 -0
  91. package/src/utils/dev-port.ts +14 -0
  92. package/src/utils/logging.ts +9 -7
  93. package/test/admin-routes.test.ts +74 -7
  94. package/test/api-generator.test.ts +0 -1
  95. package/test/api-key-permission-guard.test.ts +132 -0
  96. package/test/ast-schema-editor.test.ts +26 -0
  97. package/test/auth-routes.test.ts +1 -2
  98. package/test/backend-hooks-admin.test.ts +3 -4
  99. package/test/email-templates.test.ts +169 -0
  100. package/test/function-loader.test.ts +124 -0
  101. package/test/jwt.test.ts +4 -2
  102. package/test/mfa.test.ts +197 -0
  103. package/test/middleware.test.ts +10 -5
  104. package/test/webhook-service.test.ts +249 -0
  105. package/vite.config.ts +3 -2
  106. package/dist/index-BZoAtuqi.js.map +0 -1
  107. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  108. package/src/bootstrappers/index.ts +0 -1
  109. package/src/singleton.test.ts +0 -28
@@ -1,16 +1,17 @@
1
1
  import { Hono } from "hono";
2
2
  import { ApiError, errorHandler } from "../api/errors";
3
3
  import { randomBytes, createHash } from "crypto";
4
- import type { AuthRepository, OAuthProvider } from "./interfaces";
4
+ import type { AuthRepository, OAuthProvider, CreateUserData } from "./interfaces";
5
5
  import { generateAccessToken, generateRefreshToken, hashRefreshToken, getRefreshTokenExpiry, getAccessTokenExpiry } from "./jwt";
6
- import type { AuthOverrides } from "./auth-overrides";
7
- import { resolveAuthOverrides } from "./auth-overrides";
6
+ import type { AuthHooks } from "./auth-hooks";
7
+ import { resolveAuthHooks } from "./auth-hooks";
8
8
  import { requireAuth } from "./middleware";
9
9
  import { EmailService, EmailConfig } from "../email";
10
10
  import { getPasswordResetTemplate, getEmailVerificationTemplate, getWelcomeEmailTemplate } from "../email/templates";
11
11
  import { HonoEnv } from "../api/types";
12
12
  import { defaultAuthLimiter, strictAuthLimiter } from "./rate-limiter";
13
13
  import { z } from "zod";
14
+ import { generateTotpSecret, verifyTotp, base32Decode, generateRecoveryCodes, hashRecoveryCode } from "./mfa";
14
15
 
15
16
  /**
16
17
  * Shared configuration for auth and admin route factories.
@@ -29,10 +30,10 @@ export interface AuthModuleConfig {
29
30
  /** When true, blocks all self-registration regardless of `allowRegistration`. */
30
31
  disableSelfRegistration?: boolean;
31
32
  /**
32
- * Auth overrides for customizing password hashing, credential
33
+ * Auth hooks for customizing password hashing, credential
33
34
  * verification, lifecycle hooks, etc.
34
35
  */
35
- overrides?: AuthOverrides;
36
+ authHooks?: AuthHooks;
36
37
  /**
37
38
  * Callback that checks if bootstrap has already been completed.
38
39
  * Used by GET /auth/config to report `needsSetup` status.
@@ -101,8 +102,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
101
102
  router.onError(errorHandler);
102
103
 
103
104
  const authRepo = config.authRepo;
104
- const { emailService, emailConfig, allowRegistration = false, overrides } = config;
105
- const ops = resolveAuthOverrides(overrides);
105
+ const { emailService, emailConfig, allowRegistration = false, authHooks } = config;
106
+ const ops = resolveAuthHooks(authHooks);
106
107
 
107
108
  // ── Zod input schemas ──────────────────────────────────────────────
108
109
  const registerSchema = z.object({
@@ -191,7 +192,18 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
191
192
  async function createSessionAndTokens(userId: string, userAgent: string, ipAddress: string) {
192
193
  const roles = await authRepo.getUserRoles(userId);
193
194
  const roleIds = roles.map(r => r.id);
194
- const accessToken = generateAccessToken(userId, roleIds);
195
+
196
+ // Allow customization of access token claims via hook
197
+ let customClaims: Record<string, unknown> | undefined;
198
+ if (authHooks?.customizeAccessToken) {
199
+ const user = await authRepo.getUserById(userId);
200
+ if (user) {
201
+ const defaultClaims: Record<string, unknown> = { userId, roles: roleIds, aal: "aal1" };
202
+ customClaims = await authHooks.customizeAccessToken(defaultClaims, user);
203
+ }
204
+ }
205
+
206
+ const accessToken = generateAccessToken(userId, roleIds, "aal1", customClaims);
195
207
  const refreshToken = generateRefreshToken();
196
208
 
197
209
  await authRepo.createRefreshToken(
@@ -241,8 +253,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
241
253
  passwordHash,
242
254
  displayName: displayName || undefined
243
255
  };
244
- if (overrides?.beforeUserCreate) {
245
- createData = await overrides.beforeUserCreate(createData);
256
+ if (authHooks?.beforeUserCreate) {
257
+ createData = await authHooks.beforeUserCreate(createData);
246
258
  }
247
259
  const user = await authRepo.createUser(createData);
248
260
 
@@ -269,17 +281,19 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
269
281
  sendWelcomeEmail({ email: user.email,
270
282
  displayName: user.displayName });
271
283
 
272
- // Fire afterUserCreate hook (fire-and-forget)
273
- if (overrides?.afterUserCreate) {
274
- overrides.afterUserCreate(user).catch(err => {
275
- console.error("[AuthOverrides] afterUserCreate error:", err instanceof Error ? err.message : err);
276
- });
284
+ // Fire afterUserCreate hook
285
+ if (authHooks?.afterUserCreate) {
286
+ try {
287
+ await authHooks.afterUserCreate(user);
288
+ } catch (err) {
289
+ console.error("[AuthHooks] afterUserCreate error:", err instanceof Error ? err.message : err);
290
+ }
277
291
  }
278
292
 
279
293
  // Fire onAuthenticated hook (fire-and-forget)
280
- if (overrides?.onAuthenticated) {
281
- overrides.onAuthenticated(user, "register").catch(err => {
282
- console.error("[AuthOverrides] onAuthenticated error:", err instanceof Error ? err.message : err);
294
+ if (authHooks?.onAuthenticated) {
295
+ authHooks.onAuthenticated(user, "register").catch(err => {
296
+ console.error("[AuthHooks] onAuthenticated error:", err instanceof Error ? err.message : err);
283
297
  });
284
298
  }
285
299
 
@@ -293,11 +307,16 @@ displayName: user.displayName });
293
307
  router.post("/login", defaultAuthLimiter, async (c) => {
294
308
  const { email, password } = parseBody(loginSchema, await c.req.json());
295
309
 
310
+ // Call beforeLogin hook if provided (throw to reject)
311
+ if (authHooks?.beforeLogin) {
312
+ await authHooks.beforeLogin(email, "login");
313
+ }
314
+
296
315
  let user;
297
316
 
298
- if (overrides?.verifyCredentials) {
317
+ if (authHooks?.verifyCredentials) {
299
318
  // Full credential verification override
300
- user = await overrides.verifyCredentials(email, password, authRepo);
319
+ user = await authHooks.verifyCredentials(email, password, authRepo);
301
320
  if (!user) {
302
321
  throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
303
322
  }
@@ -325,9 +344,9 @@ displayName: user.displayName });
325
344
  );
326
345
 
327
346
  // Fire onAuthenticated hook (fire-and-forget)
328
- if (overrides?.onAuthenticated) {
329
- overrides.onAuthenticated(user, "login").catch(err => {
330
- console.error("[AuthOverrides] onAuthenticated error:", err instanceof Error ? err.message : err);
347
+ if (authHooks?.onAuthenticated) {
348
+ authHooks.onAuthenticated(user, "login").catch(err => {
349
+ console.error("[AuthHooks] onAuthenticated error:", err instanceof Error ? err.message : err);
331
350
  });
332
351
  }
333
352
 
@@ -379,6 +398,15 @@ displayName: user.displayName });
379
398
 
380
399
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
381
400
 
401
+ // Fire afterUserCreate hook
402
+ if (authHooks?.afterUserCreate) {
403
+ try {
404
+ await authHooks.afterUserCreate(user);
405
+ } catch (err) {
406
+ console.error("[AuthHooks] afterUserCreate error:", err instanceof Error ? err.message : err);
407
+ }
408
+ }
409
+
382
410
  // Auto-bootstrap: first user in the system gets admin
383
411
  const allUsers = await authRepo.listUsers();
384
412
  const isFirstUser = allUsers.length === 1 && allUsers[0].id === user.id;
@@ -502,6 +530,13 @@ displayName: user.displayName }, appName);
502
530
  // Invalidate all refresh tokens (security: log out all sessions)
503
531
  await authRepo.deleteAllRefreshTokensForUser(storedToken.userId);
504
532
 
533
+ // Fire onPasswordReset hook (fire-and-forget)
534
+ if (authHooks?.onPasswordReset) {
535
+ authHooks.onPasswordReset(storedToken.userId).catch(err => {
536
+ console.error("[AuthHooks] onPasswordReset error:", err instanceof Error ? err.message : err);
537
+ });
538
+ }
539
+
505
540
  return c.json({ success: true,
506
541
  message: "Password has been reset successfully" });
507
542
  });
@@ -649,7 +684,17 @@ message: "Email verified successfully" });
649
684
  const roles = await authRepo.getUserRoles(storedToken.userId);
650
685
  const roleIds = roles.map(r => r.id);
651
686
 
652
- const newAccessToken = generateAccessToken(storedToken.userId, roleIds);
687
+ // Allow customization of access token claims via hook
688
+ let customClaims: Record<string, unknown> | undefined;
689
+ if (authHooks?.customizeAccessToken) {
690
+ const user = await authRepo.getUserById(storedToken.userId);
691
+ if (user) {
692
+ const defaultClaims: Record<string, unknown> = { userId: storedToken.userId, roles: roleIds, aal: "aal1" };
693
+ customClaims = await authHooks.customizeAccessToken(defaultClaims, user);
694
+ }
695
+ }
696
+
697
+ const newAccessToken = generateAccessToken(storedToken.userId, roleIds, "aal1", customClaims);
653
698
  const newRefreshToken = generateRefreshToken();
654
699
 
655
700
  // Rotate refresh token (delete old, create new)
@@ -686,6 +731,19 @@ message: "Email verified successfully" });
686
731
  await authRepo.deleteRefreshToken(tokenHash);
687
732
  }
688
733
 
734
+ // Call afterLogout hook (fire-and-forget)
735
+ // Extract userId from the access token if present
736
+ const authHeader = c.req.header("authorization");
737
+ if (authHooks?.afterLogout && authHeader?.startsWith("Bearer ")) {
738
+ const { verifyAccessToken } = await import("./jwt");
739
+ const payload = verifyAccessToken(authHeader.substring(7));
740
+ if (payload) {
741
+ authHooks.afterLogout(payload.userId).catch(err => {
742
+ console.error("[AuthHooks] afterLogout error:", err instanceof Error ? err.message : err);
743
+ });
744
+ }
745
+ }
746
+
689
747
  return c.json({ success: true });
690
748
  });
691
749
 
@@ -846,5 +904,378 @@ message: "Session revoked successfully" });
846
904
  });
847
905
  });
848
906
 
907
+ // ═══════════════════════════════════════════════════════════════════════
908
+ // ANONYMOUS SIGN-IN
909
+ // ═══════════════════════════════════════════════════════════════════════
910
+
911
+ /**
912
+ * POST /auth/anonymous
913
+ * Create an anonymous user with temporary credentials
914
+ */
915
+ router.post("/anonymous", strictAuthLimiter, async (c) => {
916
+ const anonId = randomBytes(16).toString("hex");
917
+ const anonEmail = `anon_${anonId.slice(0, 8)}@anonymous.local`;
918
+
919
+ let createData: CreateUserData = {
920
+ email: anonEmail,
921
+ emailVerified: false,
922
+ isAnonymous: true
923
+ };
924
+
925
+ if (authHooks?.beforeUserCreate) {
926
+ createData = await authHooks.beforeUserCreate(createData);
927
+ }
928
+
929
+ const user = await authRepo.createUser(createData);
930
+
931
+ // Assign default role (follow register route pattern, but never auto-admin)
932
+ if (config.defaultRole) {
933
+ await authRepo.assignDefaultRole(user.id, config.defaultRole);
934
+ }
935
+
936
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
937
+ user.id,
938
+ c.req.header("user-agent") || "unknown",
939
+ c.req.header("x-forwarded-for") || "unknown"
940
+ );
941
+
942
+ // Fire afterUserCreate hook
943
+ if (authHooks?.afterUserCreate) {
944
+ authHooks.afterUserCreate(user).catch(err => {
945
+ console.error("[AuthHooks] afterUserCreate error:", err instanceof Error ? err.message : err);
946
+ });
947
+ }
948
+
949
+ // Fire onAuthenticated hook
950
+ if (authHooks?.onAuthenticated) {
951
+ authHooks.onAuthenticated(user, "anonymous").catch(err => {
952
+ console.error("[AuthHooks] onAuthenticated error:", err instanceof Error ? err.message : err);
953
+ });
954
+ }
955
+
956
+ return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken), 201);
957
+ });
958
+
959
+ /**
960
+ * POST /auth/anonymous/link
961
+ * Upgrade an anonymous user to a permanent account with email/password
962
+ */
963
+ router.post("/anonymous/link", requireAuth, async (c) => {
964
+ const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
965
+ if (!userCtx) {
966
+ throw ApiError.unauthorized("Not authenticated");
967
+ }
968
+
969
+ const user = await authRepo.getUserById(userCtx.userId);
970
+ if (!user?.isAnonymous) {
971
+ throw ApiError.badRequest("User is not anonymous", "NOT_ANONYMOUS");
972
+ }
973
+
974
+ const linkSchema = z.object({
975
+ email: z.string().email("Invalid email address").max(255),
976
+ password: z.string().min(1, "Password is required").max(128)
977
+ });
978
+ const { email, password } = parseBody(linkSchema, await c.req.json());
979
+
980
+ // Validate password strength
981
+ const passwordValidation = ops.validatePasswordStrength(password);
982
+ if (!passwordValidation.valid) {
983
+ throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
984
+ }
985
+
986
+ // Check if email is already taken
987
+ const existingUser = await authRepo.getUserByEmail(email.toLowerCase());
988
+ if (existingUser) {
989
+ throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
990
+ }
991
+
992
+ // Hash password
993
+ const passwordHash = await ops.hashPassword(password);
994
+
995
+ // Update user: set email, password, remove anonymous flag
996
+ const updatedUser = await authRepo.updateUser(user.id, {
997
+ email: email.toLowerCase(),
998
+ passwordHash,
999
+ isAnonymous: false
1000
+ });
1001
+
1002
+ if (!updatedUser) {
1003
+ throw ApiError.notFound("User not found");
1004
+ }
1005
+
1006
+ // Generate new tokens with updated identity
1007
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
1008
+ user.id,
1009
+ c.req.header("user-agent") || "unknown",
1010
+ c.req.header("x-forwarded-for") || "unknown"
1011
+ );
1012
+
1013
+ return c.json(buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken));
1014
+ });
1015
+
1016
+ // ═══════════════════════════════════════════════════════════════════════
1017
+ // MFA / TOTP
1018
+ // ═══════════════════════════════════════════════════════════════════════
1019
+
1020
+ /**
1021
+ * POST /auth/mfa/enroll
1022
+ * Start MFA enrollment: generate TOTP secret and recovery codes
1023
+ */
1024
+ router.post("/mfa/enroll", requireAuth, async (c) => {
1025
+ const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
1026
+ if (!userCtx) {
1027
+ throw ApiError.unauthorized("Not authenticated");
1028
+ }
1029
+
1030
+ const body = await c.req.json().catch(() => ({})) as Record<string, unknown>;
1031
+ const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : undefined;
1032
+ const issuer = typeof body.issuer === "string" ? body.issuer : (emailConfig?.appName || "Rebase");
1033
+
1034
+ // Get user for account name
1035
+ const user = await authRepo.getUserById(userCtx.userId);
1036
+ if (!user) {
1037
+ throw ApiError.notFound("User not found");
1038
+ }
1039
+
1040
+ // Generate TOTP secret
1041
+ const { secret, uri } = generateTotpSecret(issuer, user.email);
1042
+
1043
+ // Store the factor (unverified until user confirms with a valid code)
1044
+ const factor = await authRepo.createMfaFactor(
1045
+ user.id,
1046
+ "totp",
1047
+ secret, // In production, encrypt this before storage
1048
+ friendlyName
1049
+ );
1050
+
1051
+ // Generate recovery codes
1052
+ const codes = generateRecoveryCodes(10);
1053
+ const codeHashes = codes.map(hashRecoveryCode);
1054
+ await authRepo.createRecoveryCodes(user.id, codeHashes);
1055
+
1056
+ return c.json({
1057
+ factor: {
1058
+ id: factor.id,
1059
+ factorType: factor.factorType,
1060
+ friendlyName: factor.friendlyName
1061
+ },
1062
+ totp: {
1063
+ secret,
1064
+ uri,
1065
+ qrUri: uri // Client can use a QR library to render this
1066
+ },
1067
+ recoveryCodes: codes
1068
+ }, 201);
1069
+ });
1070
+
1071
+ /**
1072
+ * POST /auth/mfa/verify
1073
+ * Verify TOTP code to complete MFA enrollment
1074
+ */
1075
+ router.post("/mfa/verify", requireAuth, async (c) => {
1076
+ const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
1077
+ if (!userCtx) {
1078
+ throw ApiError.unauthorized("Not authenticated");
1079
+ }
1080
+
1081
+ const verifySchema = z.object({
1082
+ factorId: z.string().min(1, "Factor ID is required"),
1083
+ code: z.string().length(6, "Code must be 6 digits")
1084
+ });
1085
+ const { factorId, code } = parseBody(verifySchema, await c.req.json());
1086
+
1087
+ // Get the factor
1088
+ const factor = await authRepo.getMfaFactorById(factorId);
1089
+ if (!factor || factor.userId !== userCtx.userId) {
1090
+ throw ApiError.notFound("MFA factor not found");
1091
+ }
1092
+
1093
+ if (factor.verified) {
1094
+ throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
1095
+ }
1096
+
1097
+ // Verify the TOTP code
1098
+ const secretBuffer = base32Decode(factor.secretEncrypted);
1099
+ const isValid = verifyTotp(secretBuffer, code);
1100
+
1101
+ if (!isValid) {
1102
+ throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
1103
+ }
1104
+
1105
+ // Mark factor as verified
1106
+ await authRepo.verifyMfaFactor(factorId);
1107
+
1108
+ return c.json({ success: true, message: "MFA factor verified and enrolled" });
1109
+ });
1110
+
1111
+ /**
1112
+ * POST /auth/mfa/challenge
1113
+ * Create an MFA challenge during login (user has MFA enrolled)
1114
+ */
1115
+ router.post("/mfa/challenge", requireAuth, async (c) => {
1116
+ const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
1117
+ if (!userCtx) {
1118
+ throw ApiError.unauthorized("Not authenticated");
1119
+ }
1120
+
1121
+ const challengeSchema = z.object({
1122
+ factorId: z.string().min(1, "Factor ID is required")
1123
+ });
1124
+ const { factorId } = parseBody(challengeSchema, await c.req.json());
1125
+
1126
+ // Verify the factor belongs to this user and is verified
1127
+ const factor = await authRepo.getMfaFactorById(factorId);
1128
+ if (!factor || factor.userId !== userCtx.userId) {
1129
+ throw ApiError.notFound("MFA factor not found");
1130
+ }
1131
+
1132
+ if (!factor.verified) {
1133
+ throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
1134
+ }
1135
+
1136
+ const ipAddress = c.req.header("x-forwarded-for") || "unknown";
1137
+ const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
1138
+
1139
+ return c.json({
1140
+ challengeId: challenge.id,
1141
+ factorId: challenge.factorId,
1142
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString()
1143
+ });
1144
+ });
1145
+
1146
+ /**
1147
+ * POST /auth/mfa/challenge/verify
1148
+ * Verify a TOTP code for an active challenge, upgrade aal1 → aal2
1149
+ */
1150
+ router.post("/mfa/challenge/verify", requireAuth, async (c) => {
1151
+ const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
1152
+ if (!userCtx) {
1153
+ throw ApiError.unauthorized("Not authenticated");
1154
+ }
1155
+
1156
+ const challengeVerifySchema = z.object({
1157
+ challengeId: z.string().min(1, "Challenge ID is required"),
1158
+ code: z.string().min(1, "Code is required")
1159
+ });
1160
+ const { challengeId, code } = parseBody(challengeVerifySchema, await c.req.json());
1161
+
1162
+ // Find the challenge
1163
+ const challenge = await authRepo.getMfaChallengeById(challengeId);
1164
+ if (!challenge) {
1165
+ throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
1166
+ }
1167
+
1168
+ // Get the factor and verify ownership
1169
+ const factor = await authRepo.getMfaFactorById(challenge.factorId);
1170
+ if (!factor || factor.userId !== userCtx.userId) {
1171
+ throw ApiError.notFound("MFA factor not found");
1172
+ }
1173
+
1174
+ // Check if this is a recovery code (10 chars with hyphen) or TOTP (6 digits)
1175
+ const isRecoveryCode = code.length > 6;
1176
+ let isValid = false;
1177
+
1178
+ if (isRecoveryCode) {
1179
+ // Try recovery code
1180
+ const codeHash = hashRecoveryCode(code);
1181
+ isValid = await authRepo.useRecoveryCode(userCtx.userId, codeHash);
1182
+ } else {
1183
+ // Verify TOTP
1184
+ const secretBuffer = base32Decode(factor.secretEncrypted);
1185
+ isValid = verifyTotp(secretBuffer, code);
1186
+ }
1187
+
1188
+ if (!isValid) {
1189
+ throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
1190
+ }
1191
+
1192
+ // Mark challenge as verified
1193
+ await authRepo.verifyMfaChallenge(challengeId);
1194
+
1195
+ // Generate new access token with aal2
1196
+ const roles = await authRepo.getUserRoles(userCtx.userId);
1197
+ const roleIds = roles.map(r => r.id);
1198
+ const accessToken = generateAccessToken(userCtx.userId, roleIds, "aal2");
1199
+ const refreshToken = generateRefreshToken();
1200
+
1201
+ // Create new refresh token
1202
+ await authRepo.createRefreshToken(
1203
+ userCtx.userId,
1204
+ hashRefreshToken(refreshToken),
1205
+ getRefreshTokenExpiry(),
1206
+ c.req.header("user-agent") || "unknown",
1207
+ c.req.header("x-forwarded-for") || "unknown"
1208
+ );
1209
+
1210
+ // Fire onMfaVerified hook
1211
+ if (authHooks?.onMfaVerified) {
1212
+ authHooks.onMfaVerified(userCtx.userId, factor.id).catch(err => {
1213
+ console.error("[AuthHooks] onMfaVerified error:", err instanceof Error ? err.message : err);
1214
+ });
1215
+ }
1216
+
1217
+ return c.json({
1218
+ tokens: {
1219
+ accessToken,
1220
+ refreshToken,
1221
+ accessTokenExpiresAt: getAccessTokenExpiry()
1222
+ }
1223
+ });
1224
+ });
1225
+
1226
+ /**
1227
+ * GET /auth/mfa/factors
1228
+ * List enrolled MFA factors for the current user
1229
+ */
1230
+ router.get("/mfa/factors", requireAuth, async (c) => {
1231
+ const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
1232
+ if (!userCtx) {
1233
+ throw ApiError.unauthorized("Not authenticated");
1234
+ }
1235
+
1236
+ const factors = await authRepo.getMfaFactors(userCtx.userId);
1237
+ return c.json({
1238
+ factors: factors.map(f => ({
1239
+ id: f.id,
1240
+ factorType: f.factorType,
1241
+ friendlyName: f.friendlyName,
1242
+ verified: f.verified,
1243
+ createdAt: f.createdAt
1244
+ }))
1245
+ });
1246
+ });
1247
+
1248
+ /**
1249
+ * DELETE /auth/mfa/unenroll
1250
+ * Remove an MFA factor
1251
+ */
1252
+ router.delete("/mfa/unenroll", requireAuth, async (c) => {
1253
+ const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
1254
+ if (!userCtx) {
1255
+ throw ApiError.unauthorized("Not authenticated");
1256
+ }
1257
+
1258
+ const unenrollSchema = z.object({
1259
+ factorId: z.string().min(1, "Factor ID is required")
1260
+ });
1261
+ const { factorId } = parseBody(unenrollSchema, await c.req.json());
1262
+
1263
+ // Verify ownership
1264
+ const factor = await authRepo.getMfaFactorById(factorId);
1265
+ if (!factor || factor.userId !== userCtx.userId) {
1266
+ throw ApiError.notFound("MFA factor not found");
1267
+ }
1268
+
1269
+ await authRepo.deleteMfaFactor(factorId, userCtx.userId);
1270
+
1271
+ // If no more verified factors, clean up recovery codes
1272
+ const hasFactors = await authRepo.hasVerifiedMfaFactors(userCtx.userId);
1273
+ if (!hasFactors) {
1274
+ await authRepo.deleteAllRecoveryCodes(userCtx.userId);
1275
+ }
1276
+
1277
+ return c.json({ success: true, message: "MFA factor removed" });
1278
+ });
1279
+
849
1280
  return router;
850
1281
  }
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { pathToFileURL } from "url";
4
4
  import type { CronJobDefinition } from "@rebasepro/types";
5
+ import { logger } from "../utils/logger.js";
5
6
 
6
7
  export interface LoadedCronJob {
7
8
  /** Job ID derived from filename (e.g. "cleanup-sessions"). */
@@ -48,17 +49,13 @@ export async function loadCronJobsFromDirectory(
48
49
  const exported: unknown = mod.default;
49
50
 
50
51
  if (!exported || typeof exported !== "object") {
51
- console.warn(
52
- `[cron] ${file}: no valid default export. Skipping.`
53
- );
52
+ logger.warn(`[cron] ${file}: no valid default export. Skipping.`);
54
53
  continue;
55
54
  }
56
55
 
57
56
  const def = exported as Record<string, unknown>;
58
57
  if (typeof def.schedule !== "string" || typeof def.handler !== "function") {
59
- console.warn(
60
- `[cron] ${file}: default export missing required 'schedule' or 'handler'. Skipping.`
61
- );
58
+ logger.warn(`[cron] ${file}: default export missing required 'schedule' or 'handler'. Skipping.`);
62
59
  continue;
63
60
  }
64
61
 
@@ -74,13 +71,11 @@ export async function loadCronJobsFromDirectory(
74
71
 
75
72
  jobs.push({ id,
76
73
  definition });
77
- console.log(`⏰ Loaded cron job: ${id} (${definition.schedule})`);
74
+ logger.info(`⏰ Loaded cron job: ${id} (${definition.schedule})`);
78
75
  } catch (err: unknown) {
79
76
  const message =
80
77
  err instanceof Error ? err.message : String(err);
81
- console.error(
82
- `[cron] Failed to load ${file}: ${message}`
83
- );
78
+ logger.error(`[cron] Failed to load ${file}: ${message}`);
84
79
  }
85
80
  }
86
81
  }