Groundwork for securing the web front end (vrek gol-wqf95dq). The app does not enforce login yet. - src/lib/admin-file.ts: the admin file at ADMIN_AUTH_FILE (default .data/admin.json). scrypt hashing (N=2^16, random salt, bounded parameters, constant-time compare), zod-validated reads where a malformed file is an error, and atomic 0600 writes that won't replace an existing admin without overwrite. Plain Node, so the CLI can share it (iss-mffqscg). - src/lib/admin-auth.ts: server-only app layer; failed logins always cost one hash. - scripts/create-admin.mts + `npm run admin:create`: create or reset the admin outside the app, interactive (hidden, confirmed) or piped (iss-7xmka20). The README documents it, a no-npm Node one-liner, the file format, and password reset. - src/lib/session-token.ts and session.ts: stateless HMAC-signed session cookie, keyed from the password hash so a password change ends every session, with a 12 h sliding window (iss-e27nb70, dec-f0xar8r). 281 tests, 99.8% line coverage. Refreshes the vrek export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
// Creates (or with --force, replaces) the admin login file, outside the app, so the web
|
|
// interface never has to run unsecured. No dependencies beyond the app's own; runs on
|
|
// Node 24+ directly:
|
|
//
|
|
// npm run admin:create # prompts for username and password
|
|
// npm run admin:create -- --username admin # prompts for password only
|
|
// npm run admin:create -- --force # replace an existing admin (resets password)
|
|
// printf '%s\n' "$PW" | npm run admin:create -- --username admin # non-interactive
|
|
//
|
|
// Writes ADMIN_AUTH_FILE if set, otherwise ./.data/admin.json, owner-only (0600).
|
|
import { createInterface } from "node:readline/promises";
|
|
import { Writable } from "node:stream";
|
|
import { parseArgs } from "node:util";
|
|
import {
|
|
adminFilePath,
|
|
createAdminRecord,
|
|
passwordSchema,
|
|
readAdminFile,
|
|
usernameSchema,
|
|
writeAdminFile,
|
|
} from "../src/lib/admin-file.ts";
|
|
|
|
const { values } = parseArgs({
|
|
options: {
|
|
username: { type: "string" },
|
|
force: { type: "boolean", default: false },
|
|
help: { type: "boolean", short: "h", default: false },
|
|
},
|
|
});
|
|
|
|
if (values.help) {
|
|
console.log("Usage: npm run admin:create -- [--username NAME] [--force]");
|
|
process.exit(0);
|
|
}
|
|
|
|
const file = adminFilePath();
|
|
const interactive = process.stdin.isTTY === true;
|
|
|
|
// Echo is switched off while a password is typed.
|
|
let muted = false;
|
|
const output = new Writable({
|
|
write(chunk, _encoding, done) {
|
|
if (!muted) process.stdout.write(chunk);
|
|
done();
|
|
},
|
|
});
|
|
const rl = createInterface({ input: process.stdin, output, terminal: interactive });
|
|
// Piped input can arrive before the questions are asked, so read it line by line.
|
|
const lines = interactive ? null : rl[Symbol.asyncIterator]();
|
|
|
|
async function ask(question: string, { secret = false } = {}): Promise<string> {
|
|
if (lines) {
|
|
const next = await lines.next();
|
|
return next.done ? "" : String(next.value);
|
|
}
|
|
if (!secret) return rl.question(question);
|
|
process.stdout.write(question);
|
|
muted = true;
|
|
const answer = await rl.question("");
|
|
muted = false;
|
|
process.stdout.write("\n");
|
|
return answer;
|
|
}
|
|
|
|
function fail(message: string): never {
|
|
console.error(`Error: ${message}`);
|
|
rl.close();
|
|
process.exit(1);
|
|
}
|
|
|
|
async function main() {
|
|
const existing = await readAdminFile(file).catch((err: Error) => fail(err.message));
|
|
if (existing && !values.force) {
|
|
fail(`an admin ("${existing.username}") already exists in ${file}. Re-run with --force to replace it.`);
|
|
}
|
|
|
|
const username = values.username ?? (await ask("Admin username: "));
|
|
const name = usernameSchema.safeParse(username);
|
|
if (!name.success) fail(`username ${name.error.issues[0].message}`);
|
|
|
|
const password = await ask("Password (12+ characters): ", { secret: true });
|
|
const strong = passwordSchema.safeParse(password);
|
|
if (!strong.success) fail(`password: ${strong.error.issues[0].message}`);
|
|
if (interactive && (await ask("Repeat password: ", { secret: true })) !== password) {
|
|
fail("passwords don't match.");
|
|
}
|
|
rl.close();
|
|
|
|
await writeAdminFile(file, await createAdminRecord(username, password), {
|
|
overwrite: values.force,
|
|
});
|
|
console.log(`Admin "${username}" saved to ${file} (owner-only).`);
|
|
if (existing) console.log("Existing sessions are signed out; log in again with the new password.");
|
|
}
|
|
|
|
await main();
|