#!/usr/bin/env node /** * One-shot admin-password rotation. * * Closes the operator caveat from the `drop-public-setup` convoy: any * deployed env that ran `scripts/setup-neon-db.js` BEFORE that convoy * (commit `ff80753`, 2026-05-22) still carries the historical * `admin123` bcrypt hash in `users.password_hash` for the admin row. * The seed is idempotent (`ON CONFLICT (email) DO NOTHING`), so * re-running `npm run setup-db` does NOT rotate the password — it * just no-ops on the existing row. * * This script is the deliberate, audit-trail-preserving alternative * to "log into the app and change the password manually": it leaves * an `updated_at` timestamp on the row, validates the target row is * actually an admin before touching it, and verifies the hash matches * post-update. * * Usage: * * POSTGRES_URL=... \ * ADMIN_NEW_PASSWORD=$(openssl rand -base64 24) \ * node scripts/rotate-admin-password.js * * # Or via .env.local with both vars set: * node scripts/rotate-admin-password.js * * Required env: * - POSTGRES_URL — Neon connection string (same shape as * `npm run setup-db` uses). * - ADMIN_NEW_PASSWORD — new plaintext password. The script * fail-loud-exits BEFORE opening any DB * connection if this is unset, empty, or * shorter than 12 chars. * * Optional env: * - ADMIN_EMAIL — defaults to `admin@deckhearth.com`. Use * this if your env still has the legacy * `admin@tcgvault.com` row (pre-`pick-a-name` * convoy, 2026-05-24). Run the email-rename * migration first if so: * node scripts/migrations/2026-05-24-rename-admin-email.js * * Safety guarantees: * - Validates the target row exists AND has `role = 'admin'` before * touching it (refuses to rotate a non-admin row even if you point * ADMIN_EMAIL at it). * - Never echoes the password to stdout / stderr / shell history. * The only stdout output is the rotation status + the row's id / * email / role / updated_at. * - Verifies the new hash matches the supplied plaintext via * `bcrypt.compare` after the UPDATE, before exiting clean. If the * compare fails (extremely unlikely), exits non-zero so the * operator knows to investigate. * * NOT in scope (intentionally): * - Rotating alice / bob test-user passwords (they live in * `scripts/create-test-users.js` and are dev/test fixtures). * - A "first-login forced rotation" feature in the app (heavier * scope; would be the `force-admin-password-reset-flow` convoy). */ import dotenv from 'dotenv'; dotenv.config({ path: '.env.local' }); import { neon } from '@neondatabase/serverless'; import bcrypt from 'bcryptjs'; const MIN_PASSWORD_LENGTH = 12; const BCRYPT_ROUNDS = 12; async function main() { const postgresUrl = process.env.POSTGRES_URL; const newPassword = process.env.ADMIN_NEW_PASSWORD; const targetEmail = process.env.ADMIN_EMAIL || 'admin@deckhearth.com'; if (!postgresUrl) { console.error('❌ POSTGRES_URL is not set. Set it in .env.local or pass inline.'); process.exit(1); } if (!newPassword || newPassword.length === 0) { console.error('❌ ADMIN_NEW_PASSWORD is not set. Generate one with:'); console.error(' openssl rand -base64 24'); console.error(' Then re-run:'); console.error(' ADMIN_NEW_PASSWORD= node scripts/rotate-admin-password.js'); process.exit(1); } if (newPassword.length < MIN_PASSWORD_LENGTH) { console.error(`❌ ADMIN_NEW_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters. Got ${newPassword.length}.`); process.exit(1); } const sql = neon(postgresUrl); // `@neondatabase/serverless` returns rows directly (NOT wrapped in // { rows, rowCount } like `@vercel/postgres`). Same pattern as // scripts/migrations/2026-05-24-rename-admin-email.js. const before = await sql` SELECT id, email, role, updated_at FROM users WHERE email = ${targetEmail} `; if (before.length === 0) { console.error(`❌ No user found with email '${targetEmail}'.`); console.error(' If you ran setup-db before the pick-a-name convoy (2026-05-24),'); console.error(' the admin row may still be at admin@tcgvault.com. Run the'); console.error(' email-rename migration first:'); console.error(' node scripts/migrations/2026-05-24-rename-admin-email.js'); console.error(' Or pass ADMIN_EMAIL=admin@tcgvault.com to target the legacy row.'); process.exit(1); } if (before.length > 1) { console.error(`❌ Found ${before.length} rows matching '${targetEmail}'. The users.email column has a UNIQUE constraint so this should be impossible. Investigate manually.`); process.exit(1); } const row = before[0]; if (row.role !== 'admin') { console.error(`❌ Refusing to rotate password: row '${targetEmail}' (id=${row.id}) has role='${row.role}', not 'admin'. This script only rotates admin rows.`); process.exit(1); } console.log(`Found admin row: id=${row.id} email=${row.email} updated_at=${row.updated_at?.toISOString?.() ?? row.updated_at}`); const hashedPassword = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); await sql` UPDATE users SET password_hash = ${hashedPassword}, updated_at = CURRENT_TIMESTAMP WHERE id = ${row.id} AND email = ${targetEmail} AND role = 'admin' `; // Re-fetch + verify the new hash matches the supplied plaintext. const after = await sql` SELECT id, email, role, password_hash, updated_at FROM users WHERE id = ${row.id} `; if (after.length !== 1) { console.error(`❌ Post-update fetch returned ${after.length} rows. Investigate manually.`); process.exit(1); } const matches = await bcrypt.compare(newPassword, after[0].password_hash); if (!matches) { console.error('❌ Post-update bcrypt.compare returned false. The UPDATE may have failed silently. Investigate manually.'); process.exit(1); } console.log(`✅ Rotated password for admin row id=${row.id} email=${row.email}.`); console.log(` New updated_at: ${after[0].updated_at?.toISOString?.() ?? after[0].updated_at}`); console.log(' The new password is NOT echoed; store it in your secret manager.'); } main().catch((err) => { console.error('❌ Rotation failed:', err); process.exit(1); });