Files
electric-cherry/scripts/reset-admin.mjs
T
2026-10-17 10:55:28 -04:00

141 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Local recovery only: requires filesystem/container access to the controller.
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { emitKeypressEvents } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { hashPassword } from '../src/lib/server/auth/passwords.js';
/** @param {string} prompt @returns {Promise<string>} */
function secret(prompt) {
return new Promise((resolve, reject) => {
let value = '';
process.stdout.write(prompt);
emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();
const cleanup = () => {
process.stdin.off('keypress', listener);
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdout.write('\n');
};
/** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */
const listener = (text, key) => {
if (key?.ctrl && key.name === 'c') {
cleanup();
reject(new Error('Cancelled.'));
} else if (key?.name === 'return' || key?.name === 'enter') {
cleanup();
resolve(value);
} else if (key?.name === 'backspace') value = [...value].slice(0, -1).join('');
else if (
text &&
!key?.ctrl &&
!key?.meta &&
// eslint-disable-next-line no-control-regex -- Reject terminal control characters while collecting password input.
!/[\x00-\x1f\x7f]/.test(text) &&
[...value].length < 128
)
value += text;
};
process.stdin.on('keypress', listener);
});
}
/** @param {import('better-sqlite3').Database} database @param {string} username */
function administrator(database, username) {
const user = /** @type {{ id: string, username: string } | undefined} */ (
database
.prepare(
"SELECT id,username FROM auth_users WHERE username=? AND role='admin' AND disabled=0"
)
.get(username)
);
if (!user) throw new Error('An enabled administrator with that username was not found.');
return user;
}
/**
* Local recovery shared with regression tests; the CLI only accepts passwords on its TTY.
* @param {import('better-sqlite3').Database} database
* @param {string} username
* @param {string} password
*/
export async function recoverAdministrator(database, username, password) {
const user = administrator(database, username);
const passwordHash = await hashPassword(password);
const digest = (/** @type {string} */ value) =>
createHash('sha256').update(value).digest('hex');
database
.transaction(() => {
const changed = database
.prepare(
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0"
)
.run(passwordHash, Date.now(), user.id);
if (changed.changes !== 1)
throw new Error('Administrator changed during recovery. Try again.');
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
// Older databases may not have device credentials yet; recovery still works.
if (
database
.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='auth_login_devices'"
)
.get()
) {
database.prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(user.id);
}
database
.prepare('DELETE FROM auth_rate_limits WHERE key GLOB ? OR key IN (?,?)')
.run(
`login:account:${digest(user.username.toLowerCase())}:ip:*`,
digest(`login:account:${user.username.toLowerCase()}`),
digest(`password:account:${user.id.toLowerCase()}`)
);
database
.prepare(
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)'
)
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
})
.immediate();
}
async function main() {
const username = process.argv[2]?.toLowerCase();
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username))
throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
if (!process.stdin.isTTY)
throw new Error(
'Run this command in an interactive terminal. Passwords are never accepted as command arguments.'
);
const database = new Database(process.env.DB_PATH || './data/cherry.db', {
fileMustExist: true
});
database.pragma('busy_timeout=5000');
try {
administrator(database, username);
const password = await secret('New administrator password (8–128 characters): ');
const confirmation = await secret('Repeat password: ');
if (password !== confirmation) throw new Error('Passwords did not match.');
await recoverAdministrator(database, username, password);
console.log(
'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.'
);
} finally {
database.close();
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await main();
} catch (cause) {
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
process.exitCode = 1;
}
}