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>
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import { execFile } from "node:child_process";
|
||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { promisify } from "node:util";
|
||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||
import { verifyPassword } from "../src/lib/admin-file";
|
||
|
||
/** Runs the CLI as a real process with piped (non-interactive) stdin. */
|
||
const run = promisify(execFile);
|
||
const SCRIPT = path.join(import.meta.dirname, "create-admin.mts");
|
||
const PASSWORD = "correct horse battery";
|
||
|
||
let dir: string;
|
||
let file: string;
|
||
beforeEach(async () => {
|
||
dir = await mkdtemp(path.join(os.tmpdir(), "create-admin-"));
|
||
file = path.join(dir, "admin.json");
|
||
});
|
||
afterEach(() => rm(dir, { recursive: true, force: true }));
|
||
|
||
async function cli(args: string[], stdin: string) {
|
||
const child = run(
|
||
process.execPath,
|
||
["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", SCRIPT, ...args],
|
||
{ env: { ...process.env, ADMIN_AUTH_FILE: file } },
|
||
);
|
||
child.child.stdin!.end(stdin);
|
||
return child.then(
|
||
({ stdout, stderr }) => ({ code: 0, stdout, stderr }),
|
||
(err) => ({ code: err.code as number, stdout: err.stdout as string, stderr: err.stderr as string }),
|
||
);
|
||
}
|
||
|
||
const readAdmin = async () => JSON.parse(await readFile(file, "utf8"));
|
||
|
||
describe("npm run admin:create", () => {
|
||
it("creates an owner-only admin file with a hash of the piped password", async () => {
|
||
const res = await cli(["--username", "admin"], `${PASSWORD}\n`);
|
||
expect(res).toMatchObject({ code: 0, stderr: "" });
|
||
expect(res.stdout).toContain(`Admin "admin" saved to ${file} (owner-only).`);
|
||
|
||
const admin = await readAdmin();
|
||
expect(admin.username).toBe("admin");
|
||
expect(await verifyPassword(PASSWORD, admin.passwordHash)).toBe(true);
|
||
expect(JSON.stringify(admin)).not.toContain(PASSWORD);
|
||
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
||
});
|
||
|
||
it("reads the username from stdin too", async () => {
|
||
expect((await cli([], `ops\n${PASSWORD}\n`)).code).toBe(0);
|
||
expect((await readAdmin()).username).toBe("ops");
|
||
});
|
||
|
||
it("won't replace an existing admin without --force", async () => {
|
||
await cli(["--username", "admin"], `${PASSWORD}\n`);
|
||
const res = await cli(["--username", "intruder"], `${PASSWORD}\n`);
|
||
expect(res.code).toBe(1);
|
||
expect(res.stderr).toContain('an admin ("admin") already exists');
|
||
expect((await readAdmin()).username).toBe("admin");
|
||
});
|
||
|
||
it("replaces the admin with --force and says sessions are signed out", async () => {
|
||
await cli(["--username", "admin"], `${PASSWORD}\n`);
|
||
const res = await cli(["--username", "admin", "--force"], "a brand new password\n");
|
||
expect(res.code).toBe(0);
|
||
expect(res.stdout).toContain("Existing sessions are signed out");
|
||
expect(await verifyPassword("a brand new password", (await readAdmin()).passwordHash)).toBe(true);
|
||
});
|
||
|
||
it.each([
|
||
["a short password", ["--username", "admin"], "short\n", "password: At least 12 characters"],
|
||
["an invalid username", ["--username", "bad name"], `${PASSWORD}\n`, "username 1–64"],
|
||
["no input", [], "", "username 1–64"],
|
||
])("rejects %s and writes nothing", async (_label, args, stdin, message) => {
|
||
const res = await cli(args, stdin);
|
||
expect(res.code).toBe(1);
|
||
expect(res.stderr).toContain(message);
|
||
await expect(stat(file)).rejects.toThrow(/ENOENT/);
|
||
});
|
||
|
||
it("refuses to run over a corrupt admin file", async () => {
|
||
await writeFile(file, "{oops");
|
||
const res = await cli(["--username", "admin", "--force"], `${PASSWORD}\n`);
|
||
expect(res.code).toBe(1);
|
||
expect(res.stderr).toContain("is not valid JSON");
|
||
});
|
||
|
||
it("prints usage with --help", async () => {
|
||
const res = await cli(["--help"], "");
|
||
expect(res).toMatchObject({ code: 0 });
|
||
expect(res.stdout).toContain("Usage: npm run admin:create");
|
||
});
|
||
});
|