import { createHash, randomBytes, randomUUID } from "node:crypto"; import { Pool } from "pg"; const LIFETIME_MS = 24 * 60 * 60 * 1000; // Standalone so operators can recover an account without running the server. const main = async (): Promise => { const email = process.argv[2]?.trim().toLowerCase(); if (!email) { console.error("Usage: bun run recover "); return 1; } const pool = new Pool({ connectionString: process.env.DATABASE_URL || "postgres://postgres:password@localhost:5432/veridian", }); const client = await pool.connect(); try { await client.query("BEGIN"); // Same user-row lock as the web issuance and redemption endpoints. const found = await client.query<{ id: string; name: string }>( "SELECT id, name FROM users WHERE lower(email) = $1 FOR UPDATE", [email], ); const user = found.rows[0]; if (!user) { await client.query("ROLLBACK"); console.error(`No account with the email ${email}.`); return 1; } const token = randomBytes(32).toString("base64url"); const tokenHash = createHash("sha256").update(token).digest("hex"); const expiresAt = new Date(Date.now() + LIFETIME_MS); await client.query("DELETE FROM recovery_links WHERE user_id = $1", [user.id]); await client.query( "INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)", [randomUUID(), tokenHash, user.id, expiresAt], ); await client.query("COMMIT"); const baseUrl = (process.env.NUXT_PUBLIC_URL ?? "http://localhost:3000").replace(/\/$/, ""); console.log(`Recovery link for ${user.name} <${email}>:`); console.log(""); console.log(` ${baseUrl}/auth/recover/${token}`); console.log(""); console.log("It works once and expires in 24 hours. Using it deletes the saved API keys"); console.log("of this account, turns two-factor off and signs every device out."); return 0; } catch (error) { await client.query("ROLLBACK"); console.error("Failed to create recovery link:", error); return 1; } finally { client.release(); await pool.end(); } }; process.exit(await main());