deckhearth/scripts/rotate-admin-password.js
Randall Stillwell c40da7b83c convoy: scripts/rotate-admin-password.js — one-shot admin rotation (rotate-default-admin)
Closes the operator caveat from the `drop-public-setup` convoy: deployed
envs that ran `npm run setup-db` BEFORE `ff80753` (2026-05-22) still
carry the historical `admin123` bcrypt hash. The seed is idempotent
(`ON CONFLICT (email) DO NOTHING`), so re-running setup-db is a no-op
on existing rows.

## Design — D1: which option from the 3-option menu?

| Option | Picked? | Why |
|---|---|---|
| A. Close as no-op (defer rotation to manual app login) | No | Leaves a real-world residue if any pre-drop-public-setup env still exists — and an audit is harder than just shipping the script. |
| B. One-shot parameterized rotation script | **Yes** | Tightly scoped (~120 lines). Audit-trail-preserving (`updated_at` bump). Reusable for future rotations. No new auth surface in the app. |
| C. First-login forced password reset flow in the app | No | Right product answer, but heavier scope (new route, new flag column, UI work). Deferred as the queued `force-admin-password-reset-flow` convoy. |

## Script shape

`scripts/rotate-admin-password.js`:

- Reads `POSTGRES_URL` + `ADMIN_NEW_PASSWORD` from env (or `.env.local`).
- Optional `ADMIN_EMAIL` override; defaults to `admin@deckhearth.com`.
  Pass `admin@tcgvault.com` for envs that pre-date `pick-a-name`
  (squash `9abbab6`, 2026-05-24).
- Fail-loud-exits BEFORE opening any DB connection if:
  - `POSTGRES_URL` is unset
  - `ADMIN_NEW_PASSWORD` is unset or empty
  - `ADMIN_NEW_PASSWORD` is shorter than 12 chars
- Validates the target row EXISTS AND has `role = 'admin'` before
  touching it. Refuses to rotate non-admin rows even if `ADMIN_EMAIL`
  points at one. Refuses to rotate when multiple rows match (impossible
  given the UNIQUE(email) constraint, but checked anyway).
- Hashes with bcryptjs at 12 rounds — same as `setup-neon-db.js`.
- After UPDATE, re-fetches the row and runs `bcrypt.compare(newPassword,
  row.password_hash)`; exits non-zero if the compare fails (extremely
  unlikely, but catches silent UPDATE failures).
- NEVER echoes the password to stdout / stderr / shell history. The
  only output is the row id, email, role, and updated_at.

Same import shape as the existing `scripts/migrations/2026-05-24-rename-admin-email.js`
(ESM, `dotenv.config({ path: '.env.local' })`, `import { neon } from
'@neondatabase/serverless'`, tagged-template SQL) — keeps the "11
scripts/* using neon() directly" graveyard from gaining new patterns;
fits the `purge-neondatabase-serverless-fully` follow-up convoy's
existing audit shape.

## Out of scope

- Sibling test users (alice / bob in `scripts/create-test-users.js`) —
  dev fixtures, not real auth surfaces. Documented inline + in
  AGENTS.md Gotcha #4.
- First-login forced password reset flow — deferred as the queued
  `force-admin-password-reset-flow` convoy (it's the right product
  answer, but heavier scope than this hygiene PR).
- Email rotation (already handled by
  `scripts/migrations/2026-05-24-rename-admin-email.js`).

## Test plan

- [x] `node --check scripts/rotate-admin-password.js` — syntax OK
- [x] `npm run lint` — clean (1 pre-existing unrelated warning)
- [x] `npm run test:run` — 118 tests pass
- [ ] CI on this PR
- [ ] Operator-side smoke test (NOT covered by CI):
  - Set `ADMIN_NEW_PASSWORD=test-rotation-12chars` against a throwaway
    Neon branch DB, run the script, log in via the app with the new
    password, run the script again with a different password, log in
    again. Skip if there's no convenient throwaway DB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 23:15:35 -05:00

167 lines
6.5 KiB
JavaScript

#!/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=<value> 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);
});