Files
julian f9887c2a76
Build desktop / desktop (push) Successful in 4m28s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m59s
fix(server): seed-admin self-heals the admin role + signs a ledger event
Field failure on park-buzi: reset-db --users wipes the roles table and
points to seed-admin — which inserted the user with roleId "admin"
without recreating the role row (migration 0007 never re-runs), dying on
the role_id FOREIGN KEY. The script now upserts the built-in admin role
first (the row alone suffices — admin permissions resolve in code).

It also appends a SIGNED config_change (admin.passwordReset /
admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer: a console seed/reset by the Linux admin can't be
gated by the app, but it stays attributable in the chain. Best-effort —
no build/signing key warns loudly and proceeds (locking an admin out to
protect an audit line would invert the priority). Both paths verified
against a scratch DB reproducing the post-reset state.

Runbook: appliance-provisioning §7e — lost app-admin password reset via
FORCE=1 (interactive preferred; sessions not revoked → rotate JWT_SECRET
if theft suspected); §7d notes the FK failure + self-heal.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00

111 lines
4.0 KiB
JavaScript

// 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);