// Seed the first admin user (run once at install). // // pnpm --filter @parking/server seed-admin // -> prompts for a username (default "admin") and password // // Non-interactive (install scripts): // ADMIN_USER=admin ADMIN_PASS='strong-pass' pnpm --filter @parking/server seed-admin // // A username may also be passed as an argument. Refuses to overwrite an existing // user unless FORCE=1 (which resets that user's password). import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const bcrypt = require("bcrypt"); const { createDb, users, roles, eq } = require("@parking/db"); const DEFAULT_USERNAME = "admin"; // Lazily create one readline interface and read answers sequentially through a // single async line iterator — robust whether stdin is a TTY or a pipe (chaining // readline/promises question() over a pipe can drop buffered lines). let rl = null; let lines = null; async function prompt(label) { if (!rl) { rl = createInterface({ input: stdin, output: stdout }); lines = rl[Symbol.asyncIterator](); } stdout.write(label); const { value } = await lines.next(); return (value ?? "").trim(); } // Username: env var > CLI arg > prompt (blank -> default "admin"). let username = process.env.ADMIN_USER ?? process.argv[2]; if (!username) { username = (await prompt(`Admin username [${DEFAULT_USERNAME}]: `)) || DEFAULT_USERNAME; } let password = process.env.ADMIN_PASS; if (!password) { password = await prompt(`Password for "${username}": `); } rl?.close(); if (!password || password.length < 8) { console.error("password must be at least 8 characters"); process.exit(1); } const db = createDb(); // Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the // training reset (reset-db.mjs --users/--all) wipes the roles table and points here // to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field // failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so // the row alone is all the FK needs. await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing(); const existing = await db.select().from(users).where(eq(users.username, username)).get(); if (existing && process.env.FORCE !== "1") { console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`); process.exit(1); } const passwordHash = await bcrypt.hash(password, 12); if (existing) { await db.update(users).set({ passwordHash, roleId: "admin" }).where(eq(users.id, existing.id)); console.log(`reset password for admin "${username}"`); } else { await db.insert(users).values({ id: randomUUID(), username, passwordHash, roleId: "admin", }); console.log(`created admin "${username}"`); } // Record the action into the SIGNED ledger (config_change). A console seed/reset is // a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the // fact (the chain is the audit record; whoever holds root can reset a password, they // can't do it silently). Uses the server's own compiled EventLog + signer from dist/ // (present in the container; in a dev checkout run `pnpm build` first). Best-effort: // a missing build or signing key WARNS loudly but never blocks the seed — locking an // admin out to protect an audit line would invert the priority. try { const { EventLog } = await import("../dist/event-log.js"); const { buildSigner } = await import("../dist/signer.js"); const log = new EventLog(db, buildSigner()); await log.append({ type: "config_change", source: "manual", identity: `user:${username}`, payload: { setting: existing ? "admin.passwordReset" : "admin.seeded", username, operator: "console:seed-admin", }, }); console.log("recorded to the signed ledger (config_change)"); } catch (err) { console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`); } process.exit(0);