Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
56 lines
3.1 KiB
JavaScript
56 lines
3.1 KiB
JavaScript
#!/usr/bin/env node
|
||
// Local recovery only: requires filesystem/container access to the controller.
|
||
import Database from 'better-sqlite3';
|
||
import { emitKeypressEvents } from 'node:readline';
|
||
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
||
|
||
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');
|
||
};
|
||
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 && !/[\x00-\x1f\x7f]/.test(text) && [...value].length < 128) value += text;
|
||
};
|
||
process.stdin.on('keypress',listener);
|
||
});
|
||
}
|
||
|
||
try {
|
||
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');
|
||
const user = database.prepare("SELECT id 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.');
|
||
const password = await secret('New administrator password (15–128 characters): ');
|
||
const confirmation = await secret('Repeat password: ');
|
||
if (password !== confirmation) throw new Error('Passwords did not match.');
|
||
const passwordHash = await hashPassword(password);
|
||
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);
|
||
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();
|
||
database.close();
|
||
console.log('Administrator password reset. Existing sessions have been revoked.');
|
||
} catch (cause) {
|
||
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
||
process.exitCode=1;
|
||
}
|