fix(server): seed-admin self-heals the admin role + signs a ledger event
Build desktop / desktop (push) Successful in 4m28s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m59s

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
This commit is contained in:
2026-07-06 15:41:40 +02:00
parent 7649b897c4
commit f9887c2a76
3 changed files with 116 additions and 4 deletions
+35 -1
View File
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
const { createDb, users, eq } = require("@parking/db"); const { createDb, users, roles, eq } = require("@parking/db");
const DEFAULT_USERNAME = "admin"; const DEFAULT_USERNAME = "admin";
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
} }
const db = createDb(); 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(); const existing = await db.select().from(users).where(eq(users.username, username)).get();
if (existing && process.env.FORCE !== "1") { if (existing && process.env.FORCE !== "1") {
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`); console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
@@ -73,4 +81,30 @@ if (existing) {
}); });
console.log(`created admin "${username}"`); 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); process.exit(0);
+36 -3
View File
@@ -2,7 +2,7 @@
type: reference type: reference
tags: [parking, deployment, appliance, hardening, runbook, offline-first] tags: [parking, deployment, appliance, hardening, runbook, offline-first]
sources: [] sources: []
updated: 2026-06-30 updated: 2026-07-06
status: settled status: settled
--- ---
@@ -384,8 +384,41 @@ docker exec -it \
> setup only). It is a **training/demo** tool — never run on a production booth's data. > setup only). It is a **training/demo** tool — never run on a production booth's data.
After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. For dev (where `pnpm` exists) (`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. Since 2026-07-06 the seed
the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]]. script **self-heals the built-in `admin` role row** that this reset also wipes — before that fix the
documented re-seed died on a `role_id` FOREIGN KEY error (field failure on `park-buzi`). For dev
(where `pnpm` exists) the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
### 7e. Lost APP admin password — reset from the Linux admin account (2026-07-06)
The app's admin password lives only as a bcrypt hash in the booth DB; there is no in-app recovery
(nobody above the admin exists to send a reset). The recovery path is the **Linux `admin` account**
(the only user in the `docker` group): the seed script doubles as the password-reset tool via
`FORCE=1` — on an existing username it RESETS that user's password (and restores `roleId: admin`,
so it also rescues a demoted admin).
```bash
# Interactive (preferred — the password never lands in shell history):
docker exec -it -e FORCE=1 park-buzi-server-1 node scripts/seed-admin.mjs
# → prompts: username (Enter = admin), new password (min 8 chars)
# Non-interactive (scripted; NB the password enters the HOST's shell history):
docker exec -e FORCE=1 -e ADMIN_USER=admin -e ADMIN_PASS='new-strong-pass' \
park-buzi-server-1 node scripts/seed-admin.mjs
```
- **Attributable, not gated.** Whoever holds Linux root owns the DB file — the app cannot defend
against that actor and doesn't pretend to. What it CAN do: the script appends a **signed
`config_change` ledger event** (`admin.passwordReset` / `admin.seeded` on first seed, operator
`console:seed-admin`) so a console reset stays visible in the chain afterwards. If the signing key
is unavailable (e.g. a dev shell), it warns loudly and proceeds — locking an admin out to protect
an audit line would invert the priority. The [[threat-model]] adversary remains the *operator*,
who has no Linux account at all.
- **Sessions are NOT revoked** by a password reset — issued JWT cookies ride to expiry. A *forgotten*
password needs nothing more; a *suspected-stolen* one should also rotate the booth's `JWT_SECRET`
(Komodo Variables → redeploy), which invalidates every session instantly.
- Works on a fresh/reset DB too (the role-row self-heal above), so §7b first-seed, §7d post-reset
re-seed, and this recovery are all the same one command.
### Healthy startup + web-access ### Healthy startup + web-access
+45
View File
@@ -2414,3 +2414,48 @@ printer routing is role + failoverRank (printer-routing.ts). Wizard now skips th
hides the "Cilën barrierë shërben kjo pajisje?" panel, and stops persisting the binding for hides the "Cilën barrierë shërben kjo pajisje?" panel, and stops persisting the binding for
printers (a stale pre-fix binding drops off on next edit); the device list shows the printer's printers (a stale pre-fix binding drops off on next edit); the device list shows the printer's
ROLE instead of a bogus amber "unbound". Server never required it (no validation change). ROLE instead of a bogus amber "unbound". Server never required it (no validation change).
## [2026-07-06] update | Tariff Lab: fee breakdown — "how is this sum produced"
Operator: a lab outcome of "ALL 740 / 3h 2m" gave no derivation. Added explainFee to
@parking/shared: the SAME computeFee walk with an optional trace collector (zero fee change —
golden V1 regression still green), so Σ line items ≡ the amount by construction. Items: banded
same-price increment runs (time window · N × unit · card name), window-package occurrences,
stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE adjustments, entry
grace. /api/tariff/simulate returns `breakdown` (null when settled); the lab's Outcome panel
renders it as a lined table with the rounding note (raw min → billed min at the increment) and a
total row. 4 new engine tests pin the sum invariant + item shapes. This also largely delivers the
wiki's open "composer price preview" item — see [[tariff]].
## [2026-07-06] update | Composer: increment-unit price labels + ≠60 warning (the 60→10 trap)
Operator walked into the sharp edge the wiki flat-rate warning had already named: ladder/flat
prices are PER BILLING INCREMENT, so changing "Intervali i faturimit" 60→10 silently multiplies
every price ×6, while the price header just said "Çmimi / interval". Composer now: price labels
are DYNAMIC ("Çmimi / orë" at 60, "Çmimi / {{N}} min" otherwise — same for the flat-mode radio),
and an amber warning appears whenever the increment ≠ 60 ("çdo çmim faturohet për çdo N minuta,
JO për orë"). Band DURATIONS stay in hours — they're real wall time, increment-independent (the
operator asked if "orë" there was wrong; it isn't). See [[tariff]] (§increment).
## [2026-07-06] update | UI-wide date standard ("25 Qer") + currency-scaled composer examples
Two operator UX complaints. (1) Dates were a mix of browser-locale "7/6/2026" (raw
toLocaleString) and catalog "25 Qershor" — unified: formatDate/formatDateTime/formatClock in
lib/format.ts using new common.monthsShort ("25 Qer 14:30", year only when ≠ current, 24h clock);
formatRelativeDateTime switched to short months; ALL ~20 raw toLocale* date call sites swept
(shifts, subs, plans, drawer, snapshots, device footer, event detail, tariff composer + lab incl.
the fee-breakdown row times). Number toLocaleString (thousand separators on money) untouched.
(2) Composer example defaults were euro-scaled ("2.00"/hour ≈ 2 lekë) — now currency-aware
(ALL: 200/100 ladder, 200/500 steps, 2000 lost ticket; EUR/USD keep 2/1/2/5/20), threaded through
emptyForm/emptyLadder/emptyTier/pricingFromCard so a mode switch on an ALL card also shows lek-
plausible templates. Blank-form currency stays ALL.
## [2026-07-06] update | seed-admin signs a ledger event; lost-app-admin-password runbook (§7e)
Follow-through on the FK fix: seed-admin.mjs now appends a signed config_change
(admin.passwordReset / admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer from dist/ — a console reset by the Linux admin can't gate on the app, but it
stays attributable in the chain. Best-effort: no build/key → loud warning, seed still proceeds
(verified both paths on a scratch DB). [[appliance-provisioning]] gained §7e: FORCE=1 reset
commands (interactive preferred — keeps the password out of shell history), sessions-not-revoked
caveat + JWT_SECRET rotation for suspected theft, role-row self-heal note added to §7d.