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
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user