@rebasepro/server-core 0.6.1 → 0.8.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 (116) hide show
  1. package/dist/api/graphql/graphql-schema-generator.d.ts +9 -0
  2. package/dist/api/rest/api-generator.d.ts +2 -14
  3. package/dist/api/rest/query-parser.d.ts +2 -5
  4. package/dist/api/types.d.ts +8 -2
  5. package/dist/auth/adapter-middleware.d.ts +7 -1
  6. package/dist/auth/admin-roles-route.d.ts +18 -0
  7. package/dist/auth/admin-users-route.d.ts +28 -0
  8. package/dist/auth/api-keys/api-key-types.d.ts +8 -0
  9. package/dist/auth/auth-hooks.d.ts +17 -0
  10. package/dist/auth/builtin-auth-adapter.d.ts +3 -1
  11. package/dist/auth/index.d.ts +1 -0
  12. package/dist/auth/interfaces.d.ts +32 -2
  13. package/dist/auth/magic-link-routes.d.ts +30 -0
  14. package/dist/auth/mfa-crypto.d.ts +23 -0
  15. package/dist/auth/mfa-routes.d.ts +2 -1
  16. package/dist/auth/middleware.d.ts +9 -2
  17. package/dist/auth/routes.d.ts +3 -1
  18. package/dist/auth/session-routes.d.ts +2 -0
  19. package/dist/backend-CIxN4FVm.js.map +1 -1
  20. package/dist/base64-js-C_frYBkI.js +1687 -0
  21. package/dist/base64-js-C_frYBkI.js.map +1 -0
  22. package/dist/{dist-CZKP-Xz4.js → dist-DMO-zF6D.js} +2 -2
  23. package/dist/{dist-CZKP-Xz4.js.map → dist-DMO-zF6D.js.map} +1 -1
  24. package/dist/email/index.d.ts +2 -2
  25. package/dist/email/templates.d.ts +8 -0
  26. package/dist/email/types.d.ts +18 -0
  27. package/dist/functions/define-function.d.ts +50 -0
  28. package/dist/functions/index.d.ts +2 -0
  29. package/dist/index.d.ts +16 -18
  30. package/dist/index.es.js +1947 -2356
  31. package/dist/index.es.js.map +1 -1
  32. package/dist/index.umd.js +40615 -10203
  33. package/dist/index.umd.js.map +1 -1
  34. package/dist/init.d.ts +68 -12
  35. package/dist/jws-BqRRaK11.js +616 -0
  36. package/dist/jws-BqRRaK11.js.map +1 -0
  37. package/dist/{jwt-DHcQRGC3.js → jwt-BETC8a1J.js} +3 -611
  38. package/dist/jwt-BETC8a1J.js.map +1 -0
  39. package/dist/services/routed-realtime-service.d.ts +43 -0
  40. package/dist/{src-COaj0G3P.js → src-CB2PIpBe.js} +2 -2
  41. package/dist/{src-COaj0G3P.js.map → src-CB2PIpBe.js.map} +1 -1
  42. package/dist/src-DjzOT1kG.js +29746 -0
  43. package/dist/src-DjzOT1kG.js.map +1 -0
  44. package/dist/storage/GCSStorageController.d.ts +43 -0
  45. package/dist/storage/index.d.ts +5 -2
  46. package/dist/storage/routes.d.ts +33 -1
  47. package/dist/storage/tus-handler.d.ts +3 -1
  48. package/dist/storage/types.d.ts +28 -3
  49. package/dist/utils/dev-port.d.ts +2 -0
  50. package/package.json +13 -5
  51. package/src/api/errors.ts +16 -2
  52. package/src/api/graphql/graphql-schema-generator.ts +50 -15
  53. package/src/api/openapi-generator.ts +2 -2
  54. package/src/api/rest/api-generator.ts +44 -123
  55. package/src/api/rest/query-parser.ts +6 -23
  56. package/src/api/server.ts +10 -2
  57. package/src/api/types.ts +8 -2
  58. package/src/auth/adapter-middleware.ts +10 -2
  59. package/src/auth/admin-roles-route.ts +36 -0
  60. package/src/auth/admin-users-route.ts +302 -0
  61. package/src/auth/api-keys/api-key-middleware.ts +4 -3
  62. package/src/auth/api-keys/api-key-routes.ts +12 -2
  63. package/src/auth/api-keys/api-key-store.ts +83 -66
  64. package/src/auth/api-keys/api-key-types.ts +8 -0
  65. package/src/auth/apple-oauth.ts +2 -1
  66. package/src/auth/auth-hooks.ts +21 -0
  67. package/src/auth/bitbucket-oauth.ts +2 -1
  68. package/src/auth/builtin-auth-adapter.ts +29 -6
  69. package/src/auth/custom-auth-adapter.ts +2 -0
  70. package/src/auth/discord-oauth.ts +2 -1
  71. package/src/auth/facebook-oauth.ts +2 -1
  72. package/src/auth/github-oauth.ts +2 -1
  73. package/src/auth/gitlab-oauth.ts +2 -1
  74. package/src/auth/google-oauth.ts +8 -4
  75. package/src/auth/index.ts +2 -0
  76. package/src/auth/interfaces.ts +38 -2
  77. package/src/auth/linkedin-oauth.ts +2 -1
  78. package/src/auth/magic-link-routes.ts +167 -0
  79. package/src/auth/mfa-crypto.ts +91 -0
  80. package/src/auth/mfa-routes.ts +34 -10
  81. package/src/auth/microsoft-oauth.ts +2 -1
  82. package/src/auth/middleware.ts +14 -5
  83. package/src/auth/reset-password-admin.ts +17 -1
  84. package/src/auth/routes.ts +78 -10
  85. package/src/auth/session-routes.ts +15 -3
  86. package/src/auth/slack-oauth.ts +2 -1
  87. package/src/auth/spotify-oauth.ts +2 -1
  88. package/src/auth/twitter-oauth.ts +8 -1
  89. package/src/cron/cron-store.ts +25 -23
  90. package/src/email/index.ts +3 -2
  91. package/src/email/templates.ts +82 -0
  92. package/src/email/types.ts +16 -0
  93. package/src/functions/define-function.ts +59 -0
  94. package/src/functions/function-loader.ts +16 -0
  95. package/src/functions/index.ts +2 -0
  96. package/src/index.ts +70 -37
  97. package/src/init.ts +214 -25
  98. package/src/services/routed-realtime-service.ts +113 -0
  99. package/src/storage/GCSStorageController.ts +334 -0
  100. package/src/storage/index.ts +9 -3
  101. package/src/storage/routes.ts +191 -23
  102. package/src/storage/tus-handler.ts +16 -4
  103. package/src/storage/types.ts +25 -3
  104. package/src/utils/dev-port.ts +13 -7
  105. package/test/api-generator.test.ts +1 -1
  106. package/test/auth-config-types.test.ts +40 -0
  107. package/test/auth-routes.test.ts +54 -4
  108. package/test/custom-auth-adapter.test.ts +20 -1
  109. package/test/define-function.test.ts +45 -0
  110. package/test/env.test.ts +9 -19
  111. package/test/multi-datasource-routing.test.ts +113 -0
  112. package/test/routed-realtime-service.test.ts +86 -0
  113. package/test/storage-routes.test.ts +160 -0
  114. package/test/transform-auth-response.test.ts +305 -0
  115. package/dist/jwt-DHcQRGC3.js.map +0 -1
  116. package/test/backend-hooks-data.test.ts +0 -477
@@ -11,21 +11,30 @@ import {
11
11
  generateRecoveryCodes,
12
12
  hashRecoveryCode
13
13
  } from "./mfa";
14
+ import { encryptTotpSecret, decryptTotpSecret } from "./mfa-crypto";
14
15
  import {
15
16
  generateAccessToken,
16
17
  generateRefreshToken,
17
18
  hashRefreshToken,
18
19
  getRefreshTokenExpiry,
19
- getAccessTokenExpiry
20
+ getAccessTokenExpiry,
21
+ type AccessTokenPayload
20
22
  } from "./jwt";
21
23
  import type { AuthModuleConfig } from "./routes";
22
24
  import { resolveAuthHooks } from "./auth-hooks";
25
+ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
23
26
 
24
27
  export function mountMfaRoutes(
25
28
  router: Hono<HonoEnv>,
26
29
  config: AuthModuleConfig,
27
30
  ops: ReturnType<typeof resolveAuthHooks>,
28
- parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T
31
+ parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T,
32
+ applyTransformHook?: (
33
+ response: AuthResponsePayload,
34
+ method: TransformAuthResponseContext["method"],
35
+ request: Request,
36
+ userId: string
37
+ ) => Promise<AuthResponsePayload>
29
38
  ): void {
30
39
  const authRepo = config.authRepo;
31
40
  const emailConfig = config.emailConfig;
@@ -53,11 +62,12 @@ export function mountMfaRoutes(
53
62
  // Generate TOTP secret
54
63
  const { secret, uri } = generateTotpSecret(issuer, user.email);
55
64
 
56
- // Store the factor (unverified until user confirms with a valid code)
65
+ // Encrypt the TOTP secret before storage (AES-256-GCM envelope encryption)
66
+ const encryptedSecret = encryptTotpSecret(secret);
57
67
  const factor = await authRepo.createMfaFactor(
58
68
  user.id,
59
69
  "totp",
60
- secret, // In production, encrypt this before storage
70
+ encryptedSecret,
61
71
  friendlyName
62
72
  );
63
73
 
@@ -110,8 +120,9 @@ export function mountMfaRoutes(
110
120
  throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
111
121
  }
112
122
 
113
- // Verify the TOTP code
114
- const secretBuffer = base32Decode(factor.secretEncrypted);
123
+ // Decrypt and verify the TOTP code
124
+ const decryptedSecret = decryptTotpSecret(factor.secretEncrypted);
125
+ const secretBuffer = base32Decode(decryptedSecret);
115
126
  const isValid = verifyTotp(secretBuffer, code);
116
127
 
117
128
  if (!isValid) {
@@ -191,7 +202,8 @@ export function mountMfaRoutes(
191
202
  }
192
203
 
193
204
  // Try TOTP verification first (standard 6-digit codes)
194
- const secretBuffer = base32Decode(factor.secretEncrypted);
205
+ const decryptedSecret = decryptTotpSecret(factor.secretEncrypted);
206
+ const secretBuffer = base32Decode(decryptedSecret);
195
207
  let isValid = verifyTotp(secretBuffer, code);
196
208
 
197
209
  // Fall back to recovery code verification if TOTP didn't match
@@ -231,13 +243,17 @@ export function mountMfaRoutes(
231
243
  });
232
244
  }
233
245
 
234
- return c.json({
246
+ let mfaResponse: AuthResponsePayload = {
235
247
  tokens: {
236
248
  accessToken,
237
249
  refreshToken,
238
250
  accessTokenExpiresAt: getAccessTokenExpiry()
239
251
  }
240
- });
252
+ };
253
+ if (applyTransformHook) {
254
+ mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.userId);
255
+ }
256
+ return c.json(mfaResponse);
241
257
  });
242
258
 
243
259
  /**
@@ -267,11 +283,19 @@ export function mountMfaRoutes(
267
283
  * Remove an MFA factor
268
284
  */
269
285
  router.delete("/mfa/unenroll", requireAuth, async (c) => {
270
- const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
286
+ const userCtx = c.get("user") as AccessTokenPayload | undefined;
271
287
  if (!userCtx) {
272
288
  throw ApiError.unauthorized("Not authenticated");
273
289
  }
274
290
 
291
+ // Require aal2 (MFA-verified session) to unenroll MFA factors
292
+ if (userCtx.aal !== "aal2") {
293
+ throw ApiError.forbidden(
294
+ "MFA verification required to unenroll. Please re-authenticate with your second factor.",
295
+ "AAL2_REQUIRED"
296
+ );
297
+ }
298
+
275
299
  const unenrollSchema = z.object({
276
300
  factorId: z.string().min(1, "Factor ID is required")
277
301
  });
@@ -78,7 +78,8 @@ export function createMicrosoftProvider(config: {
78
78
  providerId: profileData.id,
79
79
  email,
80
80
  displayName: profileData.displayName || null,
81
- photoUrl: null
81
+ photoUrl: null,
82
+ emailVerified: true
82
83
  };
83
84
  } catch (error) {
84
85
  logger.error("Microsoft OAuth error", { error: error });
@@ -22,6 +22,13 @@ export type AuthResult = boolean | null | undefined | { userId?: string; uid?: s
22
22
  export interface AuthMiddlewareOptions {
23
23
  /** DataDriver to scope via withAuth() for RLS */
24
24
  driver: DataDriver;
25
+ /**
26
+ * Optional per-request driver resolver for multi-data-source backends.
27
+ * Given the request context, returns the unscoped delegate to use (e.g.
28
+ * Postgres vs Mongo, picked by the request's collection data source).
29
+ * When omitted, `driver` is used for every request.
30
+ */
31
+ resolveDriver?: (c: Context<HonoEnv>) => DataDriver;
25
32
  /**
26
33
  * If true, return 401 when no valid token is present.
27
34
  *
@@ -30,7 +37,7 @@ export interface AuthMiddlewareOptions {
30
37
  * to Postgres Row-Level Security policies.
31
38
  */
32
39
  requireAuth?: boolean;
33
- /** Optional custom validator (for non-JWT auth, e.g. Firebase Auth) */
40
+ /** Optional custom validator (for non-JWT auth, e.g. external auth providers) */
34
41
  validator?: (c: Context<HonoEnv>) => Promise<AuthResult>;
35
42
  /**
36
43
  * A static secret key for server-to-server / script authentication.
@@ -40,7 +47,7 @@ export interface AuthMiddlewareOptions {
40
47
  * roles: `["admin"]`) **without** JWT verification. The driver is scoped
41
48
  * via `withAuth()` with the service identity.
42
49
  *
43
- * This is the Rebase equivalent of a Firebase Service Account key.
50
+ * This is the Rebase equivalent of a Service Account key.
44
51
  * Set via `REBASE_SERVICE_KEY` in `.env` and pass through the backend config.
45
52
  *
46
53
  * **Security:** The comparison uses constant-time equality to prevent
@@ -232,11 +239,13 @@ export function extractUserFromToken(token: string): AccessTokenPayload | null {
232
239
  */
233
240
 
234
241
  export function createAuthMiddleware(options: AuthMiddlewareOptions): MiddlewareHandler<HonoEnv> {
235
- const { driver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
242
+ const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
236
243
 
237
244
  return async (c, next) => {
245
+ // Pick the per-request delegate (multi-data-source) before scoping.
246
+ const driver = resolveDriver ? resolveDriver(c) : baseDriver;
238
247
  if (validator) {
239
- // Custom validator path (e.g., Firebase Auth, API keys)
248
+ // Custom validator path (e.g., API keys, external auth)
240
249
  try {
241
250
  const authResult = await validator(c);
242
251
  if (authResult && typeof authResult === "object") {
@@ -282,7 +291,7 @@ code: "UNAUTHORIZED" } }, 401);
282
291
 
283
292
  // ── Service Key check ──────────────────────────────────
284
293
  // Check BEFORE JWT verification. Service keys are static
285
- // secrets (like Firebase SA keys) that grant admin access
294
+ // secrets (like Service Account keys) that grant admin access
286
295
  // for scripts, cron jobs, and server-to-server calls.
287
296
  if (serviceKey && safeCompare(token, serviceKey)) {
288
297
  const serviceUser: AccessTokenPayload = {
@@ -54,8 +54,24 @@ export function createResetPasswordRoute(config: ResetPasswordRouteConfig): Hono
54
54
  let invitationSent = false;
55
55
  let temporaryPassword: string | undefined;
56
56
 
57
+ // Parse optional body — if a password is provided, set it directly
58
+ const body = await c.req.json().catch(() => ({}));
59
+
60
+ if (body.password) {
61
+ const password = body.password as string;
62
+ const validation = ops.validatePasswordStrength(password);
63
+ if (!validation.valid) {
64
+ throw ApiError.badRequest(
65
+ `Password too weak: ${validation.errors.join(", ")}`
66
+ );
67
+ }
68
+ const passwordHash = await ops.hashPassword(password);
69
+ await authRepo.updatePassword(existing.id, passwordHash);
70
+ temporaryPassword = undefined;
71
+ invitationSent = false;
72
+ }
57
73
  // 1. Collection-level hook (closest to the data)
58
- if (collectionAuthConfig?.onResetPassword) {
74
+ else if (collectionAuthConfig?.onResetPassword) {
59
75
  const isEmailConfigured = !!(emailService && emailService.isConfigured());
60
76
  const hookResult = await collectionAuthConfig.onResetPassword(existing.id, {
61
77
  hashPassword: (password: string) => ops.hashPassword(password),
@@ -15,6 +15,8 @@ import { z } from "zod";
15
15
  import { logger } from "../utils/logger";
16
16
  import { mountMfaRoutes } from "./mfa-routes";
17
17
  import { mountSessionRoutes } from "./session-routes";
18
+ import { mountMagicLinkRoutes } from "./magic-link-routes";
19
+ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
18
20
 
19
21
  /**
20
22
  * Shared configuration for auth and admin route factories.
@@ -28,8 +30,7 @@ export interface AuthModuleConfig {
28
30
  /** Default role ID to assign to new users (default: none). Must NOT be "admin". */
29
31
  defaultRole?: string;
30
32
  /** Optional array of OAuth providers */
31
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
- oauthProviders?: OAuthProvider<any>[];
33
+ oauthProviders?: OAuthProvider<unknown>[];
33
34
  /** When true, blocks all self-registration regardless of `allowRegistration`. */
34
35
  disableSelfRegistration?: boolean;
35
36
  /**
@@ -43,6 +44,8 @@ export interface AuthModuleConfig {
43
44
  * When not provided, falls back to checking if any users exist.
44
45
  */
45
46
  isBootstrapCompleted?: () => Promise<boolean>;
47
+ /** Enable magic link (passwordless email) login. Requires email service. */
48
+ enableMagicLink?: boolean;
46
49
  }
47
50
 
48
51
  /**
@@ -53,7 +56,7 @@ function buildAuthResponse(
53
56
  roleIds: string[],
54
57
  accessToken: string,
55
58
  refreshToken: string
56
- ) {
59
+ ): AuthResponsePayload {
57
60
  return {
58
61
  user: {
59
62
  uid: user.id,
@@ -95,6 +98,29 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
95
98
  const { emailService, emailConfig, allowRegistration = false } = config;
96
99
  const ops = resolveAuthHooks(config.authHooks);
97
100
 
101
+ /**
102
+ * Apply the `transformAuthResponse` hook if provided.
103
+ *
104
+ * Errors are caught and logged — the untransformed response is returned
105
+ * as a graceful fallback so auth never breaks due to a hook failure.
106
+ */
107
+ async function applyTransformHook(
108
+ response: AuthResponsePayload,
109
+ method: TransformAuthResponseContext["method"],
110
+ request: Request,
111
+ userId: string
112
+ ): Promise<AuthResponsePayload> {
113
+ if (!ops.transformAuthResponse) return response;
114
+ try {
115
+ return await ops.transformAuthResponse(response, { userId, method, request });
116
+ } catch (err) {
117
+ logger.error("[AuthHooks] transformAuthResponse error", {
118
+ error: err instanceof Error ? err.message : err
119
+ });
120
+ return response;
121
+ }
122
+ }
123
+
98
124
  // ── Zod input schemas ──────────────────────────────────────────────
99
125
  const registerSchema = z.object({
100
126
  email: z.string().email("Invalid email address").max(255),
@@ -291,7 +317,9 @@ displayName: user.displayName });
291
317
  });
292
318
  }
293
319
 
294
- return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken), 201);
320
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
321
+ const finalResponse = await applyTransformHook(authResponse, "register", c.req.raw, user.id);
322
+ return c.json(finalResponse, 201);
295
323
  });
296
324
 
297
325
  /**
@@ -327,6 +355,11 @@ displayName: user.displayName });
327
355
 
328
356
  const isValidPassword = await ops.verifyPassword(password, user.passwordHash);
329
357
  if (!isValidPassword) {
358
+ logger.warn("[Security Audit] Auth login failure", {
359
+ eventType: "auth.login.failure",
360
+ email,
361
+ userId: user.id
362
+ });
330
363
  throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
331
364
  }
332
365
  }
@@ -344,7 +377,15 @@ displayName: user.displayName });
344
377
  });
345
378
  }
346
379
 
347
- return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
380
+ logger.info("[Security Audit] Auth login success", {
381
+ eventType: "auth.login.success",
382
+ userId: user.id,
383
+ email
384
+ });
385
+
386
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
387
+ const finalResponse = await applyTransformHook(authResponse, "login", c.req.raw, user.id);
388
+ return c.json(finalResponse);
348
389
  });
349
390
 
350
391
  /**
@@ -374,6 +415,13 @@ displayName: user.displayName });
374
415
  user = await authRepo.getUserByEmail(externalUser.email);
375
416
 
376
417
  if (user) {
418
+ // Only auto-link if the OAuth provider confirmed the email is verified
419
+ if (!externalUser.emailVerified) {
420
+ throw ApiError.forbidden(
421
+ "Cannot auto-link account: email not verified by the OAuth provider. Please log in with your password first and link the provider from your profile.",
422
+ "EMAIL_NOT_VERIFIED"
423
+ );
424
+ }
377
425
  // Link Provider to existing account
378
426
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
379
427
 
@@ -430,7 +478,9 @@ displayName: user.displayName });
430
478
  c.req.header("x-forwarded-for") || "unknown"
431
479
  );
432
480
 
433
- return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
481
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
482
+ const finalResponse = await applyTransformHook(authResponse, "oauth", c.req.raw, user.id);
483
+ return c.json(finalResponse);
434
484
  });
435
485
  }
436
486
  }
@@ -706,13 +756,15 @@ aal: "aal1" };
706
756
  ipAddress
707
757
  );
708
758
 
709
- return c.json({
759
+ const refreshResponse: AuthResponsePayload = {
710
760
  tokens: {
711
761
  accessToken: newAccessToken,
712
762
  refreshToken: newRefreshToken,
713
763
  accessTokenExpiresAt: getAccessTokenExpiry()
714
764
  }
715
- });
765
+ };
766
+ const finalResponse = await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.userId);
767
+ return c.json(finalResponse);
716
768
  });
717
769
 
718
770
  mountSessionRoutes({
@@ -721,13 +773,29 @@ aal: "aal1" };
721
773
  ops,
722
774
  parseBody,
723
775
  buildAuthResponse,
724
- createSessionAndTokens
776
+ createSessionAndTokens,
777
+ applyTransformHook
725
778
  });
726
779
 
727
780
  // ═══════════════════════════════════════════════════════════════════════
728
781
  // MFA / TOTP
729
782
  // ═══════════════════════════════════════════════════════════════════════
730
- mountMfaRoutes(router, config, ops, parseBody);
783
+ mountMfaRoutes(router, config, ops, parseBody, applyTransformHook);
784
+
785
+ // ═══════════════════════════════════════════════════════════════════════
786
+ // Magic Link (passwordless email login)
787
+ // ═══════════════════════════════════════════════════════════════════════
788
+ if (config.enableMagicLink) {
789
+ mountMagicLinkRoutes({
790
+ router,
791
+ config,
792
+ ops,
793
+ parseBody,
794
+ buildAuthResponse,
795
+ createSessionAndTokens,
796
+ applyTransformHook
797
+ });
798
+ }
731
799
 
732
800
  return router;
733
801
  }
@@ -10,6 +10,7 @@ import { hashRefreshToken } from "./jwt";
10
10
  import type { AuthModuleConfig } from "./routes";
11
11
  import { resolveAuthHooks } from "./auth-hooks";
12
12
  import type { CreateUserData } from "./interfaces";
13
+ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
13
14
 
14
15
  interface SessionRoutesConfig {
15
16
  router: Hono<HonoEnv>;
@@ -27,10 +28,16 @@ interface SessionRoutesConfig {
27
28
  accessToken: string;
28
29
  refreshToken: string;
29
30
  }>;
31
+ applyTransformHook: (
32
+ response: AuthResponsePayload,
33
+ method: TransformAuthResponseContext["method"],
34
+ request: Request,
35
+ userId: string
36
+ ) => Promise<AuthResponsePayload>;
30
37
  }
31
38
 
32
39
  export function mountSessionRoutes(opts: SessionRoutesConfig): void {
33
- const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens } = opts;
40
+ const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
34
41
  const authRepo = config.authRepo;
35
42
 
36
43
  const logoutSchema = z.object({
@@ -230,6 +237,7 @@ export function mountSessionRoutes(opts: SessionRoutesConfig): void {
230
237
  needsSetup,
231
238
  registrationEnabled: registrationAllowed,
232
239
  emailServiceEnabled: isEmailConfigured(),
240
+ magicLinkEnabled: !!config.enableMagicLink && isEmailConfigured(),
233
241
  enabledProviders
234
242
  });
235
243
  });
@@ -283,7 +291,9 @@ export function mountSessionRoutes(opts: SessionRoutesConfig): void {
283
291
  });
284
292
  }
285
293
 
286
- return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken), 201);
294
+ const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken) as AuthResponsePayload;
295
+ const finalResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
296
+ return c.json(finalResponse, 201);
287
297
  });
288
298
 
289
299
  /**
@@ -336,6 +346,8 @@ export function mountSessionRoutes(opts: SessionRoutesConfig): void {
336
346
  c.req.header("x-forwarded-for") || "unknown"
337
347
  );
338
348
 
339
- return c.json(buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken));
349
+ const authResponse = buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken) as AuthResponsePayload;
350
+ const finalResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
351
+ return c.json(finalResponse);
340
352
  });
341
353
  }
@@ -61,7 +61,8 @@ export function createSlackProvider(config: { clientId: string; clientSecret: st
61
61
  providerId: p.sub,
62
62
  email: p.email,
63
63
  displayName: p.name || null,
64
- photoUrl: p.picture || null
64
+ photoUrl: p.picture || null,
65
+ emailVerified: p.email_verified === true
65
66
  };
66
67
  } catch (error) {
67
68
  logger.error("Slack OAuth error", { error: error });
@@ -57,7 +57,8 @@ export function createSpotifyProvider(config: { clientId: string; clientSecret:
57
57
  providerId: p.id,
58
58
  email: p.email,
59
59
  displayName: p.display_name || null,
60
- photoUrl: p.images?.[0]?.url || null
60
+ photoUrl: p.images?.[0]?.url || null,
61
+ emailVerified: true
61
62
  };
62
63
  } catch (error) {
63
64
  logger.error("Spotify OAuth error", { error: error });
@@ -84,6 +84,7 @@ export function createTwitterProvider(config: { clientId: string; clientSecret:
84
84
  // generate a placeholder email — the user can update it later.
85
85
  // For apps with elevated access, fetch from v1.1 endpoint.
86
86
  let email: string | null = null;
87
+ let emailVerified = false;
87
88
  try {
88
89
  const emailResponse = await fetch(
89
90
  "https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true",
@@ -94,6 +95,9 @@ export function createTwitterProvider(config: { clientId: string; clientSecret:
94
95
  if (emailResponse.ok) {
95
96
  const emailData = await emailResponse.json() as { email?: string };
96
97
  email = emailData.email || null;
98
+ if (email) {
99
+ emailVerified = true;
100
+ }
97
101
  }
98
102
  } catch {
99
103
  // Elevated access not available — fall through
@@ -104,13 +108,16 @@ export function createTwitterProvider(config: { clientId: string; clientSecret:
104
108
  // This allows the account to be created and linked; the user should
105
109
  // update their email through the profile settings.
106
110
  email = `${profileData.id}@twitter.placeholder.rebase`;
111
+ // Placeholder emails are NOT verified — prevents auto-linking to existing accounts
112
+ emailVerified = false;
107
113
  }
108
114
 
109
115
  return {
110
116
  providerId: profileData.id,
111
117
  email,
112
118
  displayName: profileData.name || profileData.username || null,
113
- photoUrl: profileData.profile_image_url?.replace("_normal", "_400x400") || null
119
+ photoUrl: profileData.profile_image_url?.replace("_normal", "_400x400") || null,
120
+ emailVerified
114
121
  };
115
122
  } catch (error) {
116
123
  logger.error("Twitter OAuth error", { error: error });
@@ -43,7 +43,8 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
43
43
  return undefined;
44
44
  }
45
45
 
46
- const exec = admin.executeSql.bind(admin);
46
+ const exec = (sqlText: string, options?: { params?: unknown[] }) =>
47
+ admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);
47
48
 
48
49
  return {
49
50
  async ensureTable(): Promise<void> {
@@ -80,22 +81,22 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
80
81
  try {
81
82
  const resultJson = entry.result !== undefined ? JSON.stringify(entry.result) : null;
82
83
  const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;
83
- const errorEscaped = entry.error ? entry.error.replace(/'/g, "''") : null;
84
84
 
85
- await exec(`
86
- INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)
87
- VALUES (
88
- '${entry.jobId}',
89
- '${entry.startedAt}',
90
- '${entry.finishedAt}',
91
- ${entry.durationMs},
92
- ${entry.success},
93
- ${errorEscaped ? `'${errorEscaped}'` : "NULL"},
94
- ${resultJson ? `'${resultJson.replace(/'/g, "''")}'::jsonb` : "NULL"},
95
- ${logsJson ? `'${logsJson.replace(/'/g, "''")}'::jsonb` : "NULL"},
96
- ${entry.manual}
97
- )
98
- `);
85
+ await exec(
86
+ `INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)
87
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
88
+ { params: [
89
+ entry.jobId,
90
+ entry.startedAt,
91
+ entry.finishedAt,
92
+ entry.durationMs,
93
+ entry.success,
94
+ entry.error || null,
95
+ resultJson,
96
+ logsJson,
97
+ entry.manual
98
+ ]}
99
+ );
99
100
  } catch (err) {
100
101
  // Non-blocking — log persistence should never crash the scheduler
101
102
  logger.error(`[cron-store] Failed to persist log for "${entry.jobId}"`, { error: err });
@@ -104,13 +105,14 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
104
105
 
105
106
  async fetchLogs(jobId: string, limit = 50): Promise<CronJobLogEntry[]> {
106
107
  try {
107
- const rows = await exec(`
108
- SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual
109
- FROM ${TABLE}
110
- WHERE job_id = '${jobId}'
111
- ORDER BY started_at DESC
112
- LIMIT ${limit}
113
- `);
108
+ const rows = await exec(
109
+ `SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual
110
+ FROM ${TABLE}
111
+ WHERE job_id = $1
112
+ ORDER BY started_at DESC
113
+ LIMIT $2`,
114
+ { params: [jobId, limit] }
115
+ );
114
116
 
115
117
  return rows.map(rowToLogEntry);
116
118
  } catch (err) {
@@ -10,9 +10,10 @@ export type {
10
10
  PasswordResetTemplateFunction,
11
11
  EmailVerificationTemplateFunction,
12
12
  UserInvitationTemplateFunction,
13
- WelcomeEmailTemplateFunction
13
+ WelcomeEmailTemplateFunction,
14
+ MagicLinkTemplateFunction
14
15
  } from "./types";
15
16
 
16
17
  export { SMTPEmailService, createEmailService } from "./smtp-email-service";
17
18
 
18
- export { getPasswordResetTemplate, getEmailVerificationTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate } from "./templates";
19
+ export { getPasswordResetTemplate, getEmailVerificationTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, getMagicLinkTemplate } from "./templates";
@@ -386,3 +386,85 @@ Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
386
386
  html,
387
387
  text };
388
388
  }
389
+
390
+ /**
391
+ * Default magic link email template
392
+ */
393
+ export function getMagicLinkTemplate(
394
+ magicLinkUrl: string,
395
+ user: TemplateUser,
396
+ appName = "Rebase"
397
+ ): { subject: string; html: string; text: string } {
398
+ const greeting = getGreeting(user);
399
+
400
+ const subject = `Sign in to ${appName}`;
401
+
402
+ const html = `
403
+ <!DOCTYPE html>
404
+ <html>
405
+ <head>
406
+ <meta charset="utf-8">
407
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
408
+ <title>${subject}</title>
409
+ </head>
410
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
411
+ <div style="${styles.container}">
412
+ <div style="${styles.card}">
413
+ <h1 style="${styles.heading}">Sign In to ${appName}</h1>
414
+
415
+ <p style="${styles.paragraph}">
416
+ Hi ${greeting},
417
+ </p>
418
+
419
+ <p style="${styles.paragraph}">
420
+ We received a request to sign in to your ${appName} account.
421
+ Click the button below to log in:
422
+ </p>
423
+
424
+ <div style="text-align: center;">
425
+ <a href="${magicLinkUrl}" style="${styles.button}">Sign In</a>
426
+ </div>
427
+
428
+ <p style="${styles.paragraph}">
429
+ Or copy and paste this link into your browser:
430
+ </p>
431
+ <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
432
+ ${magicLinkUrl}
433
+ </p>
434
+
435
+ <div style="${styles.warning}">
436
+ ⏰ This link will expire in 15 minutes for security reasons and can only be used once.
437
+ </div>
438
+
439
+ <div style="${styles.footer}">
440
+ <p style="margin: 0;">
441
+ If you didn't request this sign-in link, you can safely ignore this email.
442
+ No action is needed.
443
+ </p>
444
+ </div>
445
+ </div>
446
+ </div>
447
+ </body>
448
+ </html>
449
+ `.trim();
450
+
451
+ const text = `
452
+ Sign In to ${appName}
453
+
454
+ Hi ${greeting},
455
+
456
+ We received a request to sign in to your ${appName} account.
457
+
458
+ Click this link to log in:
459
+ ${magicLinkUrl}
460
+
461
+ This link will expire in 15 minutes for security reasons and can only be used once.
462
+
463
+ If you didn't request this sign-in link, you can safely ignore this email.
464
+ No action is needed.
465
+ `.trim();
466
+
467
+ return { subject,
468
+ html,
469
+ text };
470
+ }