/** * Forgot password handler * Public procedure (no authentication required) * * Anti-enumeration: Always returns success even if user doesn't exist * This prevents attackers from determining which emails are registered */ import { withTransaction } from "@reviq/db"; import { sendPasswordResetEmail } from "@reviq/emails"; import { TOKEN_DURATIONS } from "../../utils/cookies.js"; import { generateExpiry, generateSecureBase58Token, } from "../../utils/crypto.js"; import { os } from "../base.js"; export const forgotPassword = os.auth.forgotPassword.handler( async ({ input, context }) => { const { email } = input; // Normalize email to lowercase const normalizedEmail = email.toLowerCase(); // Look up user by email const user = await context.db .selectFrom("users") .select(["id", "email"]) .where("email", "=", normalizedEmail) .executeTakeFirst(); // If user exists, create password reset token and send email if (user) { // Generate secure base58 token const token = generateSecureBase58Token(); // Create password reset record with 1 hour expiry const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); // Delete old tokens and insert new one in transaction await withTransaction(context.db, async (trx) => { // Delete any existing password reset tokens for this user (security measure) await trx .deleteFrom("password_resets") .where("user_id", "=", user.id) .execute(); await trx .insertInto("password_resets") .values({ user_id: user.id, token, expires_at: expiresAt, }) .execute(); }); // Send password reset email await sendPasswordResetEmail({ client: context.email.client, fromAddress: context.email.fromAddress, baseUrl: context.email.baseUrl, email: user.email, token, expiryHours: 1, }); } // Always return success (anti-enumeration) // Don't reveal whether the email exists or not return { success: true }; }, );