5 Commits

Author SHA1 Message Date
julian 266e9b0027 docs(wiki): record session findings — snapshot fix, booth rework, db reset
Build desktop / desktop (push) Successful in 4m34s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s
- entry-exit-points.md: the snapshot content-type bug + serve-side cleanType
  fix (Hikvision image/jpeg; charset="UTF-8" broke every legacy render).
- booth-exit-flow.md: the Active-Sessions/modal rework — inline barrier button
  removed -> modal; closed-within-grace view; live grace countdown; actual paid
  amount; read-only snapshot review in the closed-session view.
- local-dev-workflow.md: the gated `pnpm db:reset` training tool + flag table +
  the booth (docker exec, no pnpm) note.
- appliance-provisioning.md: new §7d — reset on the booth via docker exec into
  the server container (script ships in the deploy bundle; DATABASE_URL=
  /data/parking.sqlite), ledger-truncation warning + the two safety gates.
- index.md catalog line; log.md entries.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:43 +02:00
julian d92b8d1e6a feat(db): gated training/demo database reset CLI
A site is sometimes run live to train operators/admins; afterwards the demo
data must go without an obvious self-serve button (the operator must not be
able to wipe history). Adds packages/db/scripts/reset-db.mjs, exposed as
`pnpm db:reset` (dev) and run via `docker exec ... node
node_modules/@parking/db/scripts/reset-db.mjs` on the booth (no pnpm there).

Category flags (combinable): --financial (ledger + telemetry + snapshots +
subscription instances + blocklist; keeps users/devices/config/tariffs/plans),
--config, --users, --all. Shifts/cash/payments live as event types inside the
hash-chained ledger_events, so --financial truncates the whole signed ledger
back to empty (re-seed starts a new chain under the SAME EVENT_SIGNING_KEY —
keys untouched).

Two safety gates: RESET_ALLOWED=1 env (a real booth never sets it) + typed
DB-filename confirmation (--yes skips for CI). Single transaction + VACUUM.

Verified on throwaway dev-DB copies: both gates refuse correctly; each flag
wipes/keeps the right tables; the real dev DB is never touched.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:34 +02:00
julian 61de1fe772 feat(booth): rework Active Sessions + pay/exit modal around barrier re-open
Move the audited barrier re-open out of the inline Active-Sessions row button
and into the modal, and turn the modal's dead-ends into useful views.

- Remove the inline per-row "Open barrier" button. Clicking a row opens the
  modal, which carries the action.
- Modal recognizes a closed-within-grace transient (found && !open &&
  withinGrace) and shows the session view + Open barrier instead of dead-ending
  on "already closed" — the exact case (paid, barrier unconfirmed) that needs a
  re-pulse. Server reopenBarrier guard unchanged.
- Active-Sessions rows show a live grace-remaining countdown badge
  (exited - M:SS, 1s tick off graceExpiresAt) via new formatCountdown helper.
- Settled sessions show the ACTUAL sum paid (new SessionLookup.paidMinor,
  summed across payment events) instead of a flat "PAID" badge.
- A fully-closed (grace-expired) session's modal is no longer a dead-end: it
  shows a read-only review view (figures + paid amount + entry/exit snapshot
  strip) for dispute/audit review, with no pay/exit/open controls.

i18n sq+en parity kept; web build/lint/test green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:24 +02:00
julian cfac14e09e fix(snapshots): normalize content-type on serve so stored images render
Cameras (Hikvision) return `Content-Type: image/jpeg; charset="UTF-8"` — a
charset param on a binary body is malformed, and browsers refuse to decode an
<img> declared that way. Old capture code persisted that raw header into
snapshots.content_type (100/101 dev-DB rows); GET /api/snapshots/:id re-emitted
it verbatim, so every legacy snapshot rendered blank in the booth modal.

Capture was already hardened (encodeForStorage re-encodes to a clean
image/jpeg, fail-soft via cleanType), but the serve route trusted the stored
value. Export cleanType and apply it when setting the response header, so a
bare image/jpeg is sent regardless of what was stored — un-breaks all legacy
rows with no data migration. A stored value from an untrusted device is itself
input; normalize on capture AND on serve. Adds cleanType unit tests.

Verified: a previously-unrenderable 2560x1440 row now decodes in-browser.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:06 +02:00
julian 1b86750b0d docs(wiki): firmware/dbx-vs-TPM hardening + create disk-os-hardening page
Real-world park-buzi episode: a UEFI dbx update (delivered via fwupd/LVFS,
NOT apt) revoked a stale GRUB -> panic, and moved PCR 7 -> broke TPM-sealed
LUKS auto-unlock -> passphrase prompt. Recovered by re-sealing PCR 7.

- appliance-provisioning.md: extend the §4 re-seal runbook to name dbx; new
  §4a (fwupd-not-apt, GRUB-panic ordering, PCR-7 re-seal, operator lockdown:
  mask fwupd + remove firmware-updater snap + BIOS-password + passphrase
  escrow) incl. the --test-passphrase-silently-passes-via-TPM trap
  (--disable-external-tokens); gotchas #12/#13.
- disk-os-hardening.md: NEW — resolves a long-dangling wikilink referenced
  from ~18 pages. The *why* of host hardening (5 controls + firmware lockdown);
  commands stay in appliance-provisioning; reconciliation remains the primary
  anti-fraud control.
- index.md: expand the disk-os-hardening catalog line.
- log.md: two note entries.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 15:21:32 +02:00
20 changed files with 727 additions and 121 deletions
+15 -3
View File
@@ -98,6 +98,11 @@ export interface SessionLookup {
/** Amount owed right now (the quote). Null when no session / no active tariff. */ /** Amount owed right now (the quote). Null when no session / no active tariff. */
readonly amountMinor: number | null; readonly amountMinor: number | null;
readonly currency: string | null; readonly currency: string | null;
/** Amount actually PAID (from the latest payment event), if any. Distinct from
* `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null,
* but the operator still wants to see the sum that was collected. */
readonly paidMinor: number | null;
readonly paidCurrency: string | null;
/** True when paid AND still within the walk-back grace window. */ /** True when paid AND still within the walk-back grace window. */
readonly withinGrace: boolean; readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */ /** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
@@ -274,7 +279,8 @@ export class PayStation {
if (!entry) { if (!entry) {
return { return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null, identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null, paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null, overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
}; };
} }
@@ -289,11 +295,17 @@ export class PayStation {
let paidAt: string | null = null; let paidAt: string | null = null;
let graceExitMin: number | null = null; let graceExitMin: number | null = null;
let paidMinor: number | null = null;
let paidCurrency: string | null = null;
for (const r of rows) { for (const r of rows) {
if (r.type === "payment") { if (r.type === "payment") {
paidAt = r.occurredAt; paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number }; const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin; if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
// Sum payments (overstay top-ups append a second one) so the displayed paid total
// reflects everything collected for the session, not just the last slip.
if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor;
if (typeof p.currency === "string") paidCurrency = p.currency;
} }
} }
const graceExpiresAt = const graceExpiresAt =
@@ -328,7 +340,7 @@ export class PayStation {
return { return {
identity: id, found: true, open, identity: id, found: true, open,
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null, enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay, paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay,
subscription: isSubscription, subscriptionId, subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId), subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null, plate: plateForIdentity(this.#db, id)?.plate ?? null,
+5 -1
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db"; import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import { cleanType } from "../snapshot.js";
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see // Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence // packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
@@ -116,7 +117,10 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
async (req, reply) => { async (req, reply) => {
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get(); const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
if (!row) return reply.code(404).send({ error: "no such snapshot" }); if (!row) return reply.code(404).send({ error: "no such snapshot" });
reply.header("content-type", row.contentType); // Normalize on the way OUT too: legacy rows stored a camera's malformed
// `image/jpeg; charset="UTF-8"`, which browsers refuse to render. cleanType strips
// the bogus params back to a bare `image/jpeg` so every stored image displays.
reply.header("content-type", cleanType(row.contentType));
reply.header("cache-control", "private, max-age=31536000, immutable"); reply.header("cache-control", "private, max-age=31536000, immutable");
return reply.send(row.bytes); return reply.send(row.bytes);
}, },
+18 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import sharp from "sharp"; import sharp from "sharp";
import type { CameraDevice, Snapshot } from "@parking/devices"; import type { CameraDevice, Snapshot } from "@parking/devices";
import { captureSnapshotShared, encodeForStorage } from "./snapshot.js"; import { captureSnapshotShared, cleanType, encodeForStorage } from "./snapshot.js";
import { silentLogger } from "./test-helpers.js"; import { silentLogger } from "./test-helpers.js";
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves // captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
@@ -139,3 +139,20 @@ describe("encodeForStorage", () => {
expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback
}); });
}); });
describe("cleanType", () => {
it("strips a camera's charset cruft so a binary JPEG renders", () => {
// The exact malformed value some cameras (Hikvision) return, which broke the
// snapshot strip for every legacy row until the serve route normalized it.
expect(cleanType('image/jpeg; charset="UTF-8"')).toBe("image/jpeg");
expect(cleanType("image/jpeg; charset=utf-8")).toBe("image/jpeg");
});
it("passes a clean type through and defaults a missing one", () => {
expect(cleanType("image/jpeg")).toBe("image/jpeg");
expect(cleanType("image/png")).toBe("image/png");
expect(cleanType(null)).toBe("image/jpeg");
expect(cleanType(undefined)).toBe("image/jpeg");
expect(cleanType("")).toBe("image/jpeg");
});
});
+7 -3
View File
@@ -40,9 +40,13 @@ import type { VisionClient } from "./vision-client.js";
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280); const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80); const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */ /** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). A bare
function cleanType(ct: string): string { * `image/jpeg` renders; `image/jpeg; charset="UTF-8"` (what some cameras return, e.g.
const base = ct.split(";")[0]?.trim(); * Hikvision) is malformed for a binary body and browsers refuse to decode it. Applied
* both on capture AND when serving, so legacy rows stored before this normalization
* existed still serve a clean type. */
export function cleanType(ct: string | null | undefined): string {
const base = ct?.split(";")[0]?.trim();
return base || "image/jpeg"; return base || "image/jpeg";
} }
+42 -76
View File
@@ -1,10 +1,9 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; import { fetchActiveSessions } from "./api.js";
import { qk } from "./lib/query.js"; import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js"; import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js"; import { Panel } from "./ui/Panel.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
@@ -12,13 +11,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed // within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
// possibly-present until grace runs out). Lets the operator find a stuck car — // possibly-present until grace runs out). Lets the operator find a stuck car —
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan: // damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's // click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
// out-of-window charge, assist-open a prepaid subscriber, or review), // out-of-window charge, assist-open a prepaid subscriber, or review).
// - "Open barrier" (PAID transient sessions only) → an audited human-intervention
// re-pulse for a car that paid but whose barrier didn't confirm.
// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get
// NO inline open here — their assist-open / window-charge payment is modal-only, so
// the list can't one-click past an unpaid out-of-window charge.
// //
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they // OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
// stay listed with a distinct badge. A new period has begun (the car re-parked or is // stay listed with a distinct badge. A new period has begun (the car re-parked or is
@@ -30,11 +24,6 @@ type KindFilter = "transient" | "subscription";
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient();
// The audited barrier re-open is a money-path action (server-gated on an open
// shift); disable it unless this operator's shift is open.
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
const shiftReady = shiftOpen && shiftMine;
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: qk.activeSessions, queryKey: qk.activeSessions,
queryFn: fetchActiveSessions, queryFn: fetchActiveSessions,
@@ -43,14 +32,13 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
const reopen = useMutation({ // A 1-second clock so the within-grace countdown badge ticks live (the query only
mutationFn: (identity: string) => reopenBarrier(identity), // refetches every 15s; the badge needs per-second resolution).
onSettled: () => { const [nowMs, setNowMs] = useState(() => Date.now());
void qc.invalidateQueries({ queryKey: qk.activeSessions }); useEffect(() => {
void qc.invalidateQueries({ queryKey: qk.events }); const id = setInterval(() => setNowMs(Date.now()), 1000);
}, return () => clearInterval(id);
}); }, []);
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
// Filters: free-text search + transient-vs-subscriber. (No status filter — the status // Filters: free-text search + transient-vs-subscriber. (No status filter — the status
// column was dropped; an unpaid transient is normal and a subscriber is marked ★.) // column was dropped; an unpaid transient is normal and a subscriber is marked ★.)
@@ -77,20 +65,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
{ value: "subscription", label: t("booth.fKindSubscription") }, { value: "subscription", label: t("booth.fKindSubscription") },
]; ];
async function handleReopen(s: ActiveSession) {
setReopenMsg(null);
try {
const r = await reopen.mutateAsync(s.identity);
setReopenMsg({
id: s.identity,
ok: r.opened,
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
});
} catch (e) {
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
}
}
return ( return (
<Panel <Panel
title={t("booth.activeSessions")} title={t("booth.activeSessions")}
@@ -117,11 +91,11 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
: t("booth.noMatch")} : t("booth.noMatch")}
</div> </div>
) : ( ) : (
// A real table — aligned columns (who · plate · entry · elapsed · action). No // A real table — aligned columns (who · plate · entry · elapsed). No status
// status column: an unpaid transient is the normal case, and a subscriber is // column: an unpaid transient is the normal case, and a subscriber is already
// already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row // marked with ★ + holder name. Overstay (a top-up is owed) keeps a row tint so
// tint so that fraud-relevant signal isn't lost. The whole row is clickable // that fraud-relevant signal isn't lost. The whole row is clickable (→ pay/exit
// (→ pay/exit modal); the trailing cell holds the audited Open-barrier action. // modal).
<table className="w-full text-[0.75rem] tabular-nums"> <table className="w-full text-[0.75rem] tabular-nums">
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted"> <thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr> <tr>
@@ -129,31 +103,43 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th> <th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th> <th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th> <th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
<th className="px-2 py-1.5" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map((s) => { {filtered.map((s) => {
const msg = reopenMsg?.id === s.identity ? reopenMsg : null; // EXITED-WITHIN-GRACE: a paid transient whose exit is recorded but the
// Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid // barrier didn't confirm — it lingers here until grace runs out. Mark it
// but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and // so the operator can tell it apart from a still-inside car (clicking it
// NOT a subscription (assist-open lives in the modal). An unpaid transient // opens the modal's manual barrier re-open, not a pay flow).
// gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard. const closedInGrace = !s.open && s.withinGrace && !s.subscription;
const canReopen = s.paidAt && !s.overstay && !s.subscription; // Live grace-remaining for the badge (M:SS). Null once it lapses — the
// next refetch (≤15s) reclassifies the row (overstay / gone); until then
// we show a generic label so the badge doesn't flicker empty.
const graceLeft = closedInGrace ? formatCountdown(s.graceExpiresAt, nowMs) : null;
return ( return (
<tr <tr
key={s.identity} key={s.identity}
onClick={() => onPick(s.identity)} onClick={() => onPick(s.identity)}
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${ className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
s.overstay ? "bg-term-red/5" : "" s.overstay ? "bg-term-red/5" : closedInGrace ? "bg-term-amber/5 text-term-muted" : ""
}`} }`}
title={t("booth.openPayExit")} title={closedInGrace ? t("booth.openReopenBarrier") : t("booth.openPayExit")}
> >
<td className="px-2 py-1.5 text-term-text"> <td className="px-2 py-1.5 text-term-text">
{s.subscription ? ( {s.subscription ? (
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span> <span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
) : ( ) : (
s.identity <span className="inline-flex items-center gap-1.5">
{s.identity}
{closedInGrace && (
<span
className="rounded border border-term-amber/60 px-1 text-[0.5625rem] uppercase tracking-wider tabular-nums text-term-amber"
title={t("booth.exitedGraceTitle")}
>
{graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")}
</span>
)}
</span>
)} )}
</td> </td>
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
@@ -170,28 +156,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
{formatRelativeDateTime(s.enteredAt, t)} {formatRelativeDateTime(s.enteredAt, t)}
</td> </td>
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted"> <td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
{formatDuration(s.enteredAt, new Date().toISOString())} {/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */}
</td> {formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
<td className="px-2 py-1.5 text-right">
{canReopen && (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
onClick={(e) => {
e.stopPropagation(); // don't also open the pay/exit modal
void handleReopen(s);
}}
className="btn btn-pay btn-sm"
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
>
{t("booth.openBarrier")}
</button>
)}
{msg && (
<span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</span>
)}
</td> </td>
</tr> </tr>
); );
+84 -8
View File
@@ -73,6 +73,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
// exit. A normal within-grace paid session is NOT payable (it's settled). See // exit. A normal within-grace paid session is NOT payable (it's settled). See
// booth-exit-flow.md / reopenBarrier server guard. // booth-exit-flow.md / reopenBarrier server guard.
const isOverstay = s?.overstay === true; const isOverstay = s?.overstay === true;
// CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier
// didn't confirm — it lingers in the active list until grace runs out (the "phantom
// re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the
// normal review flow; the only action is an audited manual re-pulse of the barrier.
// (A grace-EXPIRED closed session falls through to the plain "already closed" notice.)
const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription);
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can // A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns // owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable // it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
@@ -278,21 +284,54 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</div> </div>
)} )}
{s && s.found && !s.open && ( {s && s.found && !s.open && !closedWithinGrace && (
// A fully-closed session (exited, grace expired): no action to take, but the
// operator may still need to REVIEW the evidence (entry/exit snapshots + plate)
// — e.g. a dispute about a car that just left. Show the closed notice, the
// figures, and the snapshot strip read-only. No tender / voucher / open here.
<>
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber"> <div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })} {t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
</div> </div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
<Row
label={t("pay.duration")}
value={
s.enteredAt ? formatDuration(s.enteredAt, s.exitedAt ?? new Date().toISOString()) : "—"
}
/>
{alreadyPaid && s.paidMinor != null && s.paidCurrency && (
<Row label={t("pay.paidAmount")} value={formatMoney(s.paidMinor, s.paidCurrency)} valueClass="text-term-green" />
)}
</div>
<SnapshotStrip identity={identity} />
</>
)} )}
{s && s.found && s.open && ( {s && s.found && (s.open || closedWithinGrace) && (
<> <>
{/* Session figures */} {/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums"> <div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} /> <Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} /> {/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
<Row
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
/>
<Row <Row
label={t("pay.duration")} label={t("pay.duration")}
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"} value={
s.enteredAt
? formatDuration(
s.enteredAt,
(closedWithinGrace ? s.exitedAt : null) ?? new Date().toISOString(),
)
: "—"
}
/> />
<Row <Row
label={t("pay.statusLabel")} label={t("pay.statusLabel")}
@@ -301,6 +340,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
? t("pay.subscription") ? t("pay.subscription")
: isOverstay : isOverstay
? t("pay.overstay") ? t("pay.overstay")
: closedWithinGrace
? t("pay.closedWithinGrace")
: alreadyPaid : alreadyPaid
? t("pay.paid") ? t("pay.paid")
: t("pay.unpaid") : t("pay.unpaid")
@@ -310,6 +351,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
? "text-term-cyan" ? "text-term-cyan"
: isOverstay : isOverstay
? "text-term-red" ? "text-term-red"
: closedWithinGrace
? "text-term-amber"
: alreadyPaid : alreadyPaid
? "text-term-green" ? "text-term-green"
: "text-term-amber" : "text-term-amber"
@@ -322,7 +365,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
amount is the TOP-UP delta, not the whole stay. */} amount is the TOP-UP delta, not the whole stay. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2"> <div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted"> <span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")} {subWindowDue
? t("pay.windowCharge")
: isSubscription
? t("pay.plan")
: isOverstay
? t("pay.topUp")
: alreadyPaid && s.paidMinor != null
? // Settled session — the figure is the sum collected, not a quote.
t("pay.paidAmount")
: t("pay.total")}
</span> </span>
<span className="text-3xl font-bold text-term-cyan"> <span className="text-3xl font-bold text-term-cyan">
{subWindowDue && s.amountMinor != null && s.currency {subWindowDue && s.amountMinor != null && s.currency
@@ -331,6 +383,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
? t("pay.prepaid") ? t("pay.prepaid")
: s.amountMinor != null && s.currency : s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency) ? formatMoney(s.amountMinor, s.currency)
: alreadyPaid && s.paidMinor != null && s.paidCurrency
? // Settled (within-grace / closed): show the sum actually collected.
formatMoney(s.paidMinor, s.paidCurrency)
: alreadyPaid : alreadyPaid
? t("booth.badgePaid") ? t("booth.badgePaid")
: t("pay.noTariff")} : t("pay.noTariff")}
@@ -361,6 +416,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</div> </div>
)} )}
{/* Closed-within-grace: the exit is already paid + recorded; the barrier
just didn't confirm. Explain that the only action is a manual re-pulse. */}
{closedWithinGrace && (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.closedWithinGraceHint")}
</div>
)}
{/* Snapshots */} {/* Snapshots */}
<SnapshotStrip identity={identity} /> <SnapshotStrip identity={identity} />
@@ -382,8 +445,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</div> </div>
)} )}
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */} {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not
{phase !== "done" && !isSubscription && ( for a closed-within-grace session — its exit is already recorded. */}
{phase !== "done" && !isSubscription && !closedWithinGrace && (
<label className="flex items-center gap-2 text-[0.75rem]"> <label className="flex items-center gap-2 text-[0.75rem]">
<input <input
type="checkbox" type="checkbox"
@@ -464,7 +528,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
> >
{t("common.cancel")} {t("common.cancel")}
</button> </button>
{isSubscription ? ( {closedWithinGrace ? (
// Paid + exited but the barrier didn't confirm — the only action is
// an audited manual re-pulse (the server re-opens without signing a
// second exit). No payment, no voucher; mirrors reopenBarrier's guard.
<button
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="btn btn-pay btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
) : isSubscription ? (
subWindowDue && !windowPaid ? ( subWindowDue && !windowPaid ? (
// Step 1 — a window charge is owed: take payment first. The // Step 1 — a window charge is owed: take payment first. The
// barrier open is the explicit next step (revealed once paid). // barrier open is the explicit next step (revealed once paid).
+3
View File
@@ -1168,6 +1168,9 @@ export interface SessionLookup {
paidAt: string | null; paidAt: string | null;
amountMinor: number | null; amountMinor: number | null;
currency: string | null; currency: string | null;
/** Amount actually PAID (sum of payment events), independent of what's owed now. */
paidMinor: number | null;
paidCurrency: string | null;
withinGrace: boolean; withinGrace: boolean;
graceExpiresAt: string | null; graceExpiresAt: string | null;
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began; /** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
+16
View File
@@ -22,6 +22,22 @@ export function formatDuration(fromIso: string, toIso: string): string {
return h > 0 ? `${h}h ${m}m` : `${m}m`; return h > 0 ? `${h}h ${m}m` : `${m}m`;
} }
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
* hour). Returns null once expired (or for a bad/empty input) so callers can drop the
* badge. Pass `nowMs` (a ticking clock) to make it update each second. */
export function formatCountdown(untilIso: string | null, nowMs: number = Date.now()): string | null {
if (!untilIso) return null;
const ms = Date.parse(untilIso) - nowMs;
if (!Number.isFinite(ms) || ms <= 0) return null;
const total = Math.ceil(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const ss = String(s).padStart(2, "0");
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${ss}`;
return `${m}:${ss}`;
}
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */ /** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
export function formatMinutes(mins: number): string { export function formatMinutes(mins: number): string {
if (!Number.isFinite(mins) || mins < 0) return "—"; if (!Number.isFinite(mins) || mins < 0) return "—";
+8
View File
@@ -160,6 +160,10 @@ export const en: Catalog = {
fEvtVoid: "Void", fEvtVoid: "Void",
fEvtAnomaly: "Anomaly", fEvtAnomaly: "Anomaly",
openPayExit: "Open pay / exit", openPayExit: "Open pay / exit",
openReopenBarrier: "Open — paid, awaiting barrier",
exitedGrace: "exited · grace",
exitedGraceLeft: "exited · {{time}}",
exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.",
openBarrier: "Open barrier", openBarrier: "Open barrier",
openBarrierTitle: "Human-intervention barrier open (audited)", openBarrierTitle: "Human-intervention barrier open (audited)",
barrierOpened: "barrier opened", barrierOpened: "barrier opened",
@@ -874,14 +878,18 @@ export const en: Catalog = {
ticket: "Ticket", ticket: "Ticket",
entry: "Entry", entry: "Entry",
now: "Now", now: "Now",
exit: "Exit",
duration: "Duration", duration: "Duration",
statusLabel: "Status", statusLabel: "Status",
paid: "PAID", paid: "PAID",
unpaid: "UNPAID", unpaid: "UNPAID",
overstay: "OVERSTAY", overstay: "OVERSTAY",
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.", overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
closedWithinGrace: "EXITED · GRACE",
closedWithinGraceHint: "Paid and exit recorded — the barrier didn't confirm yet. The car stays listed until the grace period ends. Open the barrier manually if it's still waiting.",
topUp: "New period due", topUp: "New period due",
total: "Total", total: "Total",
paidAmount: "Paid",
noTariff: "no tariff", noTariff: "no tariff",
tender: "Tender", tender: "Tender",
cash: "Cash", cash: "Cash",
+14 -6
View File
@@ -162,10 +162,14 @@ export const sq = {
fEvtVoid: "Anulim", fEvtVoid: "Anulim",
fEvtAnomaly: "Anomali", fEvtAnomaly: "Anomali",
openPayExit: "Hap pagesën / daljen", openPayExit: "Hap pagesën / daljen",
openReopenBarrier: "Hap — paguar, pret barrierën",
exitedGrace: "doli · në afat",
exitedGraceLeft: "doli · {{time}}",
exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.",
openBarrier: "Hap barrierën", openBarrier: "Hap barrierën",
openBarrierTitle: "Hap barrierën manualisht", openBarrierTitle: "Hap barrierën manualisht",
barrierOpened: "barriera u hap", barrierOpened: "barriera u hap",
openManually: "hape me dorë", openManually: "hape manualisht",
// session row badges // session row badges
badgeExiting: "duke dalë", badgeExiting: "duke dalë",
badgePaid: "paguar", badgePaid: "paguar",
@@ -242,9 +246,9 @@ export const sq = {
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur", "exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)", "exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)", "exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape me dorë", "exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape manualisht",
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape me dorë", "exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape manualisht",
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape me dorë", "exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht",
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)", "exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)", "exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet", "sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
@@ -890,20 +894,24 @@ export const sq = {
ticket: "Bileta", ticket: "Bileta",
entry: "Hyrja", entry: "Hyrja",
now: "Tani", now: "Tani",
exit: "Dalja",
duration: "Kohëzgjatja", duration: "Kohëzgjatja",
statusLabel: "Statusi", statusLabel: "Statusi",
paid: "PAGUAR", paid: "PAGUAR",
unpaid: "PAPAGUAR", unpaid: "PAPAGUAR",
overstay: "TEJ AFATIT", overstay: "TEJ AFATIT",
overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.", overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.",
closedWithinGrace: "Paguar",
closedWithinGraceHint: "Pagesa dhe dalja u regjistruan — barriera nuk u konfirmua ende. Makina mbetet në listë derisa të mbarojë afati. Hapni barrierën manualisht nëse pret ende.",
topUp: "Periudha e re për pagesë", topUp: "Periudha e re për pagesë",
total: "Totali", total: "Totali",
paidAmount: "Paguar",
noTariff: "pa tarifë", noTariff: "pa tarifë",
tender: "Mënyra", tender: "Mënyra",
cash: "Para", cash: "Para",
card: "Kartë", card: "Kartë",
printExitVoucher: "Printo biletë dalje", printExitVoucher: "Printo biletë dalje",
selfExitHint: "(klienti del vetë te dalja)", selfExitHint: "(klienti del duke skanuar biletën)",
payAndOpen: "Paguaj + hap barrierën", payAndOpen: "Paguaj + hap barrierën",
payAndVoucher: "Paguaj + printo biletën", payAndVoucher: "Paguaj + printo biletën",
openBarrier: "Hap barrierën", openBarrier: "Hap barrierën",
@@ -926,7 +934,7 @@ export const sq = {
windowCharge: "JASHTË ORARIT", windowCharge: "JASHTË ORARIT",
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.", windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).", subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.", voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del duke skanuar biletën.",
// payment receipt (transparency slip) // payment receipt (transparency slip)
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)", receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
receiptReprinted: "Fatura u riprintua në {{printer}}.", receiptReprinted: "Fatura u riprintua në {{printer}}.",
+2 -1
View File
@@ -12,7 +12,8 @@
"lint": "turbo run lint", "lint": "turbo run lint",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
"test": "turbo run test", "test": "turbo run test",
"seed:admin": "pnpm --filter @parking/server seed-admin" "seed:admin": "pnpm --filter @parking/server seed-admin",
"db:reset": "pnpm --filter @parking/db db:reset"
}, },
"devDependencies": { "devDependencies": {
"turbo": "2.9.18", "turbo": "2.9.18",
+2 -1
View File
@@ -26,7 +26,8 @@
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate", "db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate",
"db:migrate:runtime": "node scripts/migrate-runtime.mjs" "db:migrate:runtime": "node scripts/migrate-runtime.mjs",
"db:reset": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" node scripts/reset-db.mjs"
}, },
"dependencies": { "dependencies": {
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
// DESTRUCTIVE training/demo reset of the SQLite DB at DATABASE_URL. Deletes rows from
// whole CATEGORIES of tables so a site can be re-used to TRAIN operators/admins without
// leaving demo data behind. This is intentionally a CLI script (no UI button) so it
// cannot be triggered casually — and it is double-gated so it never runs on a real booth.
//
// ⚠ This TRUNCATES the append-only, hash-chained, SIGNED ledger (`ledger_events`).
// That is the anti-fraud record. A partial delete would break the chain, so a
// financial reset wipes the whole ledger back to empty (re-seeding starts a NEW
// chain under the same EVENT_SIGNING_KEY — the key is NOT touched here). Only ever
// do this on a TRAINING/DEMO box. See wiki/concepts/append-only-event-chain.md.
//
// Flags (combinable; at least one required):
// --all every category below (a blank-slate box)
// --financial transactional history: ledger (entry/exit/payment/void/shift/cash/
// anomaly), device telemetry, snapshots, subscription INSTANCES +
// their credentials/plates, blocklist. KEEPS users, devices, config,
// tariffs, subscription PLANS.
// --config site_config, devices, setup_state (re-runs first-run setup),
// tariffs + tariff_versions, subscription_plans.
// --users users, roles, role_permissions, auth sessions. (After this or --all,
// re-seed an admin: apps/server/scripts/seed-admin.mjs.)
//
// Safety gates (BOTH required):
// 1. env RESET_ALLOWED=1 — a real booth never sets this.
// 2. type the DB filename — interactive confirmation (skip with --yes ONLY in CI).
//
// Usage:
// RESET_ALLOWED=1 DATABASE_URL=apps/server/parking.sqlite \
// node packages/db/scripts/reset-db.mjs --financial
import { createInterface } from "node:readline";
import { basename, resolve } from "node:path";
import { existsSync } from "node:fs";
import Database from "better-sqlite3";
// --- Category → tables (child tables BEFORE parents; we also disable FKs for the txn). ---
const CATEGORIES = {
financial: [
"ledger_events",
"device_events",
"snapshots",
"subscription_plates",
"subscription_credentials",
"subscriptions",
"blocklist",
],
config: ["site_config", "devices", "setup_state", "tariff_versions", "tariffs", "subscription_plans"],
users: ["sessions", "role_permissions", "users", "roles"],
};
function parseArgs(argv) {
const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2)));
const wantAll = flags.has("all");
const cats = wantAll ? Object.keys(CATEGORIES) : Object.keys(CATEGORIES).filter((c) => flags.has(c));
return { cats, autoYes: flags.has("yes"), wantAll };
}
function die(msg) {
console.error(`[reset] ${msg}`);
process.exit(1);
}
async function confirm(promptText, expected) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((res) => rl.question(promptText, res));
rl.close();
return answer.trim() === expected;
}
async function main() {
const url = process.env.DATABASE_URL;
if (!url) die("DATABASE_URL is required");
const dbPath = resolve(url);
if (!existsSync(dbPath)) die(`no database at ${dbPath}`);
const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2));
if (cats.length === 0) {
die("nothing to do — pass --all, --financial, --config, and/or --users");
}
// GATE 1: env opt-in. A production booth never sets this.
if (process.env.RESET_ALLOWED !== "1") {
die(
`refusing to reset ${dbPath}\n` +
` set RESET_ALLOWED=1 to enable (real booths never set this).`,
);
}
// Resolve the ordered, de-duplicated table list for the chosen categories.
const tables = [];
for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t);
console.error(`\n⚠ DESTRUCTIVE RESET`);
console.error(` db : ${dbPath}`);
console.error(` categories: ${cats.join(", ")}${wantAll ? " (= everything)" : ""}`);
console.error(` tables : ${tables.join(", ")}`);
if (cats.includes("financial")) {
console.error(` NOTE: this TRUNCATES the signed append-only ledger. Training/demo only.`);
}
console.error("");
// GATE 2: typed confirmation of the DB filename (skippable only with --yes, for CI).
if (!autoYes) {
const fname = basename(dbPath);
const ok = await confirm(`Type the db filename to confirm (${fname}): `, fname);
if (!ok) die("confirmation did not match — aborted, nothing changed.");
}
const sqlite = new Database(dbPath);
try {
// FKs OFF for the wipe so we can delete in any order without ordering hazards;
// a single transaction makes it all-or-nothing.
sqlite.pragma("foreign_keys = OFF");
const wipe = sqlite.transaction(() => {
const counts = {};
for (const t of tables) {
const before = sqlite.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n;
sqlite.prepare(`DELETE FROM "${t}"`).run();
counts[t] = before;
}
return counts;
});
const counts = wipe();
sqlite.pragma("foreign_keys = ON");
// Reclaim space + reset the WAL so the file shrinks (demo boxes get re-used a lot).
sqlite.exec("VACUUM");
console.error(`[reset] done. Rows deleted:`);
for (const t of tables) console.error(` ${String(counts[t]).padStart(7)} ${t}`);
if (cats.includes("users") || wantAll) {
console.error(
`\n[reset] users were cleared — re-seed an admin:\n` +
` ADMIN_USER=admin ADMIN_PASS='…' node apps/server/scripts/seed-admin.mjs`,
);
}
} finally {
sqlite.close();
}
}
main().catch((e) => die(e.message));
+17 -1
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, domain, booth, exit, payment, threat-model] tags: [parking, domain, booth, exit, payment, threat-model]
sources: [] sources: []
updated: 2026-06-18 updated: 2026-06-30
status: open status: open
--- ---
@@ -162,6 +162,22 @@ server-side in `reopenBarrier`: allow only when `subscription` OR (`paidAt != nu
paidAt + graceExitMin`). A future reason-required *force exit* for genuine disputes (car already gone) paidAt + graceExitMin`). A future reason-required *force exit* for genuine disputes (car already gone)
would be a separately-audited path — see Open. would be a separately-audited path — see Open.
> **Open-barrier moved INTO the modal — the inline row button is gone (2026-06-30).** The audited
> re-pulse was previously an inline button on the paid-in-grace Active Sessions *row*. It was removed:
> clicking any row now opens the modal, which carries the Open-barrier action. Why: a paid-and-exited
> session is `open=false`, so clicking its row used to dead-end on *"This session is already closed"* —
> useless for the very case (paid, barrier didn't confirm) where the operator needs to re-pulse. The
> modal now recognizes a **closed-within-grace** transient (`found && !open && withinGrace`) and renders
> the session view + **Open barrier** instead of the dead-end notice. The server guard is unchanged
> (`reopenBarrier` already handled the closed-but-in-grace case — the T-397815c0 fix above). The
> Active Sessions list distinguishes these rows with a **live grace-remaining countdown** badge
> (`exited · M:SS`, ticking each second off `graceExpiresAt`) instead of a static "exited" label.
> Settled amounts now show the **actual sum paid** (new `SessionLookup.paidMinor`, summed across
> payments) rather than a flat "PAID" badge. And a **fully-closed (grace-expired) session** is no longer
> a pure dead-end: its modal shows a read-only **review view** — figures + paid amount + the entry/exit
> [[entry-exit-points#camera-snapshots-evidence-not-a-gate|snapshot strip]] — so an operator can review
> evidence for a car that just left (disputes/audits), with no pay/exit/open controls.
### Subscription occurrences in the booth (built 2026-06-18) ### Subscription occurrences in the booth (built 2026-06-18)
A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by
+16 -1
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, architecture, devices, setup] tags: [parking, architecture, devices, setup]
sources: [] sources: []
updated: 2026-06-16 updated: 2026-06-30
--- ---
# Entry / Exit Points (pool-of-spaces model) # Entry / Exit Points (pool-of-spaces model)
@@ -114,6 +114,21 @@ re-encode is **storage-only** — ANPR recognition runs on the **original full-r
(downscaling hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot (downscaling hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot
(`snapshot.ts` `encodeForStorage`). (`snapshot.ts` `encodeForStorage`).
> **Content-type bug — every legacy snapshot rendered blank (fixed 2026-06-30).** Symptom: *no*
> snapshot showed in the booth modal. Root cause: some cameras (Hikvision) return
> `Content-Type: image/jpeg; charset="UTF-8"` — a charset param on a binary body is **malformed**, and
> browsers refuse to decode an `<img>` declared that way. Old capture code persisted that raw header
> into `snapshots.content_type` (100 of 101 rows in the dev DB), and the serve route
> (`GET /api/snapshots/:id`) re-emitted it **verbatim** → broken render for every legacy row. The
> capture path was *already* hardened (`encodeForStorage` re-encodes to a clean `image/jpeg`; its
> fail-soft branch calls `cleanType` to strip `; charset=…`), so NEW rows were fine — but the serve
> route trusted the stored value. Fix: the route now also runs `cleanType(row.contentType)` on the way
> out (a bare `image/jpeg`), which un-breaks all legacy rows with **no data migration**. Verified: a
> previously-unrenderable 2560×1440 row now decodes in-browser. Lesson: **normalize a camera-supplied
> content-type both on capture AND on serve** — a stored value from an untrusted device is itself input.
> The stored `content_type` column could be backfilled to `image/jpeg` for cleanliness, but serving
> normalizes so it isn't required.
**Retention (2026-06-28, resolves the old open question) — DISK-PRESSURE safety valve.** Snapshots **Retention (2026-06-28, resolves the old open question) — DISK-PRESSURE safety valve.** Snapshots
are unsigned/advisory, so they prune freely. The day-to-day shrink is the re-encode above; pruning is are unsigned/advisory, so they prune freely. The day-to-day shrink is the re-encode above; pruning is
a backstop that only fires under real disk pressure. A **daily** check (`snapshot-retention.ts` a backstop that only fires under real disk pressure. A **daily** check (`snapshot-retention.ts`
+42 -1
View File
@@ -2,7 +2,7 @@
type: reference type: reference
tags: [parking, dev-environment, workflow] tags: [parking, dev-environment, workflow]
sources: [] sources: []
updated: 2026-06-15 updated: 2026-06-30
--- ---
# Local Dev Workflow # Local Dev Workflow
@@ -54,3 +54,44 @@ Production uses an **nginx** reverse proxy (`deploy/nginx.conf`) for the same sa
`ADMIN_USER=.. ADMIN_PASS=.. pnpm seed:admin`. Reset a password: add `FORCE=1`. `ADMIN_USER=.. ADMIN_PASS=.. pnpm seed:admin`. Reset a password: add `FORCE=1`.
- Hardware test scripts (UHPPOTE): `apps/server/scripts/uhppote-listen.mjs` (live events), - Hardware test scripts (UHPPOTE): `apps/server/scripts/uhppote-listen.mjs` (live events),
`uhppote-relay.mjs` (guarded door-open). See [[uhppote-controller]]. `uhppote-relay.mjs` (guarded door-open). See [[uhppote-controller]].
## Database reset — training / demo only (2026-06-30)
A site is sometimes run live to **train** operators/admins on the real app; afterwards the demo data
must go without leaving an obvious self-serve button (an operator must not be able to wipe history).
So the reset is a **CLI script**, not UI: `packages/db/scripts/reset-db.mjs`, run via `pnpm db:reset`.
```bash
RESET_ALLOWED=1 pnpm db:reset --financial # default DB = apps/server/parking.sqlite
RESET_ALLOWED=1 DATABASE_URL=/path node packages/db/scripts/reset-db.mjs --all
```
**Category flags** (combinable; ≥1 required) — grounded in which tables hold what:
| Flag | Wipes | Keeps |
| --- | --- | --- |
| `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** |
| `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions, subscription plans | everything else |
| `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else |
| `--all` | every table (blank slate) | — |
> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]].**
> That is the anti-fraud record; a *partial* delete would break the hash chain, so a financial reset
> wipes the whole ledger back to empty (re-seeding starts a NEW chain under the **same**
> `EVENT_SIGNING_KEY` — the key is **not** touched). This is the opposite of how the ledger is meant to
> behave, hence the gates below. It is a **training/demo** tool; never point it at a live booth.
**Two safety gates ([[threat-model|operator-as-adversary]]):**
1. **`RESET_ALLOWED=1`** env must be set — a real booth never sets it, so the command is inert in
production even if typed.
2. **Typed confirmation** of the DB filename (interactive). `--yes` skips it for CI/scripted training
setup only.
Runs as a single transaction (all-or-nothing) + `VACUUM` to shrink the re-used demo DB. After
`--users`/`--all` (users cleared), re-seed an admin: `pnpm seed:admin`. The `EVENT_SIGNING_KEY` and
`BACKUP_KEY` are intentionally left alone (see [[backup-recovery]] on key custody).
> **On the BOOTH there is no `pnpm`** — only Docker containers. `pnpm db:reset` is the *dev* form;
> on an appliance, run the same script via `docker exec` into the `server` container
> (`node node_modules/@parking/db/scripts/reset-db.mjs …`, `DATABASE_URL=/data/parking.sqlite`).
> Full booth procedure: [[appliance-provisioning]] §7d.
+108 -4
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-27 updated: 2026-06-30
status: settled status: settled
--- ---
@@ -103,9 +103,67 @@ sudo reboot
- Still prompts = PCR mismatch; type the passphrase (NOT locked out), then retry with - Still prompts = PCR mismatch; type the passphrase (NOT locked out), then retry with
`--tpm2-pcrs=0`. The `password` slot + `crypttab.bak` make this fully reversible. `--tpm2-pcrs=0`. The `password` slot + `crypttab.bak` make this fully reversible.
> **Re-seal runbook:** a BIOS update / Secure Boot change alters PCR 7 → the TPM refuses → boot > **Re-seal runbook:** a BIOS update / Secure Boot change / **UEFI dbx (revocation list) update**
> falls back to the passphrase prompt (not a brick). After such a change, re-run step 4's > alters PCR 7 → the TPM refuses → boot falls back to the passphrase prompt (not a brick). After
> `systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` to re-bind. > such a change, re-run step 4's
> `systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` to re-bind, then
> reboot to confirm unattended unlock returned.
### 4a. Firmware / UEFI dbx updates break PCR 7 — and are an OPERATOR threat (VERIFIED 2026-06-30)
The PCR-7 re-seal hazard above is **not** a rare event — the most common trigger is a **UEFI `dbx`
(Secure Boot revocation database) update**, and it bit the real `park-buzi` booth on 2026-06-28:
- **What `dbx` is:** the Secure Boot blocklist of known-vulnerable bootloader/shim hashes
(vendor = Microsoft). It is delivered by **`fwupd`/LVFS — a channel SEPARATE from APT** (the GNOME
"Firmware Updater", which on Ubuntu is the **`firmware-updater` snap**, surfaces it). `apt list
--upgradable` being clean does NOT mean a firmware/dbx update isn't pending.
- **The GRUB panic (root cause):** applying a *new* dbx against a *stale* GRUB/shim revokes the
installed bootloader → Secure Boot refuses to load it → **unbootable / GRUB "panic"**. The fix is
ordering: `apt full-upgrade` (current `grub-efi`/`shim-signed`) FIRST, *then* dbx. A fresh reinstall
ships a current GRUB, so reinstalling recovers it.
- **It moves PCR 7:** even with a current GRUB, applying dbx changes the Secure-Boot-policy
measurement → the TPM (slot 1) refuses to release the key → next boot **drops to the slot-0
passphrase prompt**. Recover with the re-seal runbook above. VERIFIED: on `park-buzi` the dbx
update went through, the box rebooted to a passphrase prompt, the slot-0 passphrase unlocked it,
and `systemd-cryptenroll --wipe-slot=tpm2 … --tpm2-pcrs=7` restored silent auto-unlock.
**Threat-model consequence ([[threat-model]]: the operator is the adversary).** A firmware/dbx update
on a TPM-sealed booth → the booth won't boot unattended and needs the slot-0 passphrase. So the
operator must be unable to *trigger* a firmware update, and must never hold the passphrase. Lock it
down (DONE on `park-buzi` 2026-06-30):
```bash
# 1. Kill the firmware-update DAEMON (the GUI "Update" button then fails with no daemon):
sudo systemctl mask fwupd.service fwupd-refresh.timer
systemctl is-enabled fwupd.service fwupd-refresh.timer # → masked / masked (persists across reboot)
# 2. Remove the operator-facing GUI so the screen is never even presented (Ubuntu = a snap):
sudo snap remove firmware-updater
snap list | grep -i firmware # → no output (re-check: seeded snaps can re-install)
```
Plus: the **BIOS admin password** (§1) must gate *entering setup / changing settings* (a
supervisor/admin password, not just a boot password) so the operator can't disable Secure Boot or
change boot order — either of which also breaks the seal. And the **slot-0 passphrase stays
off-machine / escrowed** (same custody as `EVENT_SIGNING_KEY` / `BACKUP_KEY`); it is an admin-only
recovery secret, used on-site during a maintenance window, never known to operators.
> **Net:** firmware/dbx updates become an **admin-only, on-site, deliberate** action. The booth is
> unattended-bootable only while the firmware/Secure-Boot state is frozen — that is the security
> property, not a bug. Legitimate firmware maintenance now costs: physical presence + the slot-0
> passphrase + a PCR-7 re-enroll.
> **⚠ Gotcha — `cryptsetup … --test-passphrase` SILENTLY passes via the TPM.** Before any
> firmware/dbx change, you must *prove a typed passphrase still unlocks the disk* (the TPM-independent
> safety net). But `sudo cryptsetup open --test-passphrase /dev/sda3` with a TPM2 token enrolled will
> succeed **without prompting** — the TPM auto-answers (it unlocks the tpm2 *slot*, e.g. slot 1), a
> FALSE positive that proves nothing about a human-typeable key. Force a real test with
> `--disable-external-tokens` (→ `No usable token is available.` then it prompts; success on slot 0 =
> the passphrase genuinely works):
> ```bash
> sudo cryptsetup open --test-passphrase /dev/sda3 --disable-external-tokens --verbose
> ```
## 5. GRUB password — EDIT-ONLY (VERIFIED 2026-06-23) ## 5. GRUB password — EDIT-ONLY (VERIFIED 2026-06-23)
@@ -293,6 +351,42 @@ ENV=prod ./booth.sh up
`booth.sh` runs from wherever it sits next to the compose files (the booth deploys them flat, e.g. `booth.sh` runs from wherever it sits next to the compose files (the booth deploys them flat, e.g.
`/opt/parking_systems/`). See [[container-deployment]]. `/opt/parking_systems/`). See [[container-deployment]].
### 7d. Reset the DB for TRAINING/DEMO — `docker exec`, not `pnpm` (2026-06-30)
A site is sometimes run live to **train** operators/admins on the real app; afterwards the demo data
must go without leaving an obvious self-serve button (the [[threat-model|operator must not be able to
wipe history]]). The reset is a **CLI script** (`packages/db/scripts/reset-db.mjs`), and on the booth
there is **no `pnpm`** — only the running containers. So run it the same way as the seed-admin step in
§7b: **`docker exec` into the `server` container**, where the script ships inside the deploy bundle at
`node_modules/@parking/db/scripts/reset-db.mjs` (the same place the boot migrator lives — see the
entrypoint). `DATABASE_URL` in-container is **`/data/parking.sqlite`** (the `parking-data` volume).
```bash
# On the booth (or via Komodo's terminal on the server container). Category flags:
# --financial ledger (entry/exit/payment/void/shift/cash/anomaly) + device_events + snapshots +
# subscription INSTANCES/credentials/plates + blocklist. KEEPS users/devices/config/
# tariffs/subscription PLANS.
# --config site_config, devices, setup_state (re-runs first-run setup), tariffs + versions, plans.
# --users users, roles, role_permissions, auth sessions. --all every table.
docker exec -it \
-e RESET_ALLOWED=1 \
-e DATABASE_URL=/data/parking.sqlite \
park-buzi-server-1 \
node node_modules/@parking/db/scripts/reset-db.mjs --financial
```
> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]]** —
> the anti-fraud record. A *partial* delete would break the hash chain, so a financial reset wipes the
> whole ledger back to empty (re-seeding starts a NEW chain under the **same** `EVENT_SIGNING_KEY`/
> `BACKUP_KEY` — the keys are **not** touched). This is the opposite of how the ledger is meant to
> behave, hence the two gates: it refuses unless **`RESET_ALLOWED=1`** is set (a real booth never sets
> it) **and** you type the DB filename to confirm (`parking.sqlite`; `--yes` skips that for scripted
> 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
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. For dev (where `pnpm` exists)
the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
### Healthy startup + web-access ### Healthy startup + web-access
Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights), Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights),
@@ -317,6 +411,16 @@ works; the desktop app is a separate workstream.
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot → 6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting. breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
### Firmware / dbx gotchas (2026-06-30, §4a)
12. **UEFI dbx ships via `fwupd`/LVFS, NOT APT.** `apt list --upgradable` clean ≠ no firmware update
pending. A new dbx vs a stale GRUB → revoked bootloader → **unbootable / GRUB panic** (`apt
full-upgrade` first, then dbx). And dbx **moves PCR 7** → breaks TPM auto-unlock → passphrase
prompt → re-seal (§4 runbook). Mask `fwupd` + remove the `firmware-updater` snap so the operator
can't trigger it.
13. `cryptsetup … --test-passphrase` **silently passes via the TPM token** (false safety signal). Use
`--disable-external-tokens` to actually force a typed-passphrase test before any firmware change.
### Komodo deploy gotchas (2026-06-27) ### Komodo deploy gotchas (2026-06-27)
7. Periphery `core_address` is **Core's reverse-proxy URL** (`https://komodo.infra.msai.al`), NOT 7. Periphery `core_address` is **Core's reverse-proxy URL** (`https://komodo.infra.msai.al`), NOT
+92
View File
@@ -0,0 +1,92 @@
---
type: decision
tags: [parking, hardening, threat-model, luks, tpm, secure-boot, grub, firmware, offline-first]
sources: [parking-system-architecture]
updated: 2026-06-30
status: settled
---
# Disk & OS hardening (booth appliance)
The host-level defences that raise the cost of **offline, physical tamper** of a booth PC: full-disk
encryption, TPM-sealed auto-unlock, Secure Boot, a GRUB edit-lock, an unprivileged operator account,
and locking firmware updates away from the operator. This page is the **rationale (the *why*)**; the
step-by-step verified commands live in the [[appliance-provisioning]] runbook (§3–5c, §4a). Settled
across the first real provisioning (2026-06-23) and the firmware-update episode (2026-06-30).
> ⚠ **This is the secondary control, not the main event.** The load-bearing anti-fraud mechanism is
> [[reconciliation]] over the [[append-only-event-chain|signed event chain]]. Disk/OS hardening
> defends the [[threat-model|outsider-with-the-box]] and raises the cost of offline tamper — it does
> **not** replace reconciliation, and it cannot stop a *legitimate, logged-in* operator from
> committing fraud through the app (that's what the signed ledger + reconciliation are for).
## What it defends against
The appliance sits on-site, physically reachable by the [[threat-model|booth operator (the primary
adversary)]] and by an outsider who can open the case. Without host hardening, either can:
- **Pull the SSD** and read/alter the SQLite ledger offline → FDE (LUKS) defeats this.
- **Boot a live USB** to mount and edit the disk → Secure Boot + TPM-sealing (PCR 7) defeats booting
a tampered/unsigned kernel; FDE keeps the data unreadable.
- **Edit the GRUB cmdline** (`init=/bin/bash`) for a no-login root shell on the *decrypted* disk →
the GRUB edit-lock defeats this (the TPM seal does NOT — see below).
- **Escalate from the operator login** (sudo, `docker`/`lxd` groups) → the unprivileged-operator
model defeats this.
## The five controls and why each is shaped the way it is
| Control | Choice | Why this shape (the load-bearing nuance) |
| --- | --- | --- |
| **FDE** | LUKS, **passphrase** at install (not the installer's "hardware-backed" option) | The 26.04 installer's automated FDE profiler fails on this firmware (`PCR_UNUSABLE`/`dbt`). Passphrase LUKS + a *manual* TPM seal sidesteps it and lets us pick PCRs. The passphrase slot is the **permanent recovery key**. |
| **TPM auto-unlock** | `systemd-cryptenroll`, **PCR 7 only** | Unattended reboot is a hard requirement (no operator types a passphrase). PCR 7 = Secure-Boot policy: catches the attack that matters (disabling Secure Boot) **without** churning on kernel/GRUB updates (PCRs 4/8/9 → would drop to passphrase every boot). **Keep BOTH slots** — slot 0 password (recovery), slot 1 tpm2 (auto-unlock); the TPM is never the only key. |
| **Secure Boot** | Enabled, **Deployed Mode**, stock MS keys | Ubuntu's signed shim needs stock `db`. Reaching the installer with Secure Boot ON is itself proof the MS third-party UEFI CA is trusted. |
| **GRUB edit-lock** | password, **edit-only** (`--unrestricted`) | Closes the `init=/bin/bash` root-shell hole. **The PCR-7 TPM seal does NOT cover this** — editing the cmdline doesn't change PCR 7, so the TPM still releases the key and the attacker lands on the decrypted disk. Edit-only so the box still boots **unattended** (password required only to *edit* entries). |
| **Operator account** | unprivileged, auto-login; separate **admin**+sudo | The operator is the adversary; their OS identity must not be able to escalate. Strip `sudo`, and the latent-escalation groups `lxd`/`docker` (both root-equivalent) + `lpadmin`. Admin is a distinct, no-auto-login identity. |
See [[tpm]] for the TPM-2.0 analysis (why PCR-only sealing, bus-sniff limits, TPM-vs-[[atecc608|ATECC608]]).
## Firmware / UEFI dbx updates — a hardening surface AND an operator threat
Settled 2026-06-30 after a real incident on `park-buzi`. This is the non-obvious one, because it
turns a routine "security update" into a booth-availability risk:
- **UEFI `dbx`** (the Secure Boot revocation database) and BIOS firmware are delivered by
**`fwupd`/LVFS — a channel SEPARATE from APT** (Ubuntu's GNOME "Firmware Updater" = the
`firmware-updater` snap). A clean `apt list --upgradable` does NOT mean no firmware update is pending.
- **It can brick boot:** a new dbx against a *stale* GRUB/shim **revokes the installed bootloader** →
Secure Boot refuses it → unbootable / GRUB panic. Correct order: `apt full-upgrade` (current
`grub-efi`/`shim-signed`) **first**, then dbx.
- **It breaks auto-unlock:** even with a current GRUB, applying dbx **moves PCR 7** → the TPM refuses
the LUKS key → next boot falls back to the slot-0 passphrase prompt (not a brick). Recover with the
PCR-7 re-seal runbook ([[appliance-provisioning]] §4/§4a).
- **Threat-model consequence:** a firmware/dbx update makes the booth need a passphrase to boot
unattended — so the **operator must be unable to trigger one, and must never hold the passphrase.**
Lock it down: `systemctl mask fwupd.service fwupd-refresh.timer`, `snap remove firmware-updater`,
a **BIOS admin password** that gates *entering setup* (so the operator can't disable Secure Boot /
change boot order), and the **slot-0 passphrase escrowed off-machine** (same custody as
`EVENT_SIGNING_KEY` / `BACKUP_KEY`). Firmware maintenance becomes **admin-only, on-site, deliberate**.
> The booth is unattended-bootable **only while the firmware / Secure-Boot state is frozen** — that is
> the security property, not a bug. The cost is that legitimate firmware maintenance now needs physical
> presence + the slot-0 passphrase + a PCR-7 re-enroll.
> **⚠ Verification trap:** `cryptsetup … --test-passphrase` **silently passes via the TPM token** (a
> false safety signal). Before any firmware change, prove a *typed* passphrase still unlocks the disk
> with `--disable-external-tokens` — see [[appliance-provisioning]] §4a.
## Where the commands live
This page is the rationale. The **verified, run-on-real-hardware commands** are in
[[appliance-provisioning]]: §1 BIOS, §2 Secure-Boot live-USB check, §3 encrypted install (the `dbt`
workaround), §4 TPM seal (PCR 7) + re-seal runbook, **§4a firmware/dbx lockdown**, §5 GRUB edit-lock,
§5c admin-vs-operator accounts. Komodo Periphery is folded into the same hardened surface as a
root-capable remote agent — see [[fleet-deployment-komodo]] (bind to the NetBird interface only).
## Relates
- [[appliance-provisioning]] — the runbook (commands); this page is its *why*.
- [[tpm]] — TPM 2.0 analysis (sealing, PCR choice, limits, vs ATECC608).
- [[threat-model]] — the operator-adversary framing this hardening serves.
- [[reconciliation]] / [[append-only-event-chain]] — the **primary** anti-fraud control this
complements, never replaces.
- [[fleet-deployment-komodo]] — Periphery as part of the trusted computing base.
+2 -2
View File
@@ -55,7 +55,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
## Concepts — integrity & anti-fraud ## Concepts — integrity & anti-fraud
- [[append-only-event-chain]] — append-only + hash chain + ATECC608 signing = unforgeable log. - [[append-only-event-chain]] — append-only + hash chain + ATECC608 signing = unforgeable log.
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is. - [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
- [[disk-os-hardening]] — LUKS/GRUB/Secure Boot; worthwhile but not the main event. - [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only. - [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only.
## Concepts — device architecture & safety ## Concepts — device architecture & safety
@@ -111,7 +111,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
- [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian. - [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian.
## Dev environment (reference) ## Dev environment (reference)
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin. - [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin, the gated `db:reset` training/demo tool.
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after. - [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
## Decisions ## Decisions
+81
View File
@@ -1993,3 +1993,84 @@ the in-container path as the UI target. Acknowledged limitation: backups are NOT
plug-a-USB-and-go); adding a destination is an admin host+compose change. Partly a feature vs the plug-a-USB-and-go); adding a destination is an admin host+compose change. Partly a feature vs the
operator-adversary threat model (operator can't redirect backups to a removable stick). USB-automount-to- operator-adversary threat model (operator can't redirect backups to a removable stick). USB-automount-to-
container flow deferred/not built. container flow deferred/not built.
## [2026-06-30] note | UEFI dbx / firmware update vs TPM-sealed LUKS — GRUB panic + PCR-7 re-seal + operator lockdown (park-buzi)
Real-world on park-buzi. The GNOME "Firmware Updater" (Ubuntu = the `firmware-updater` snap) surfaced a
pending UEFI dbx (Secure Boot revocation DB) update, vendor Microsoft, delivered by fwupd/LVFS — a channel
SEPARATE from APT (apt list --upgradable was clean except 2 cups packages). 2026-06-28 a dbx update against a
stale GRUB revoked the bootloader → GRUB panic / unbootable → user reinstalled Ubuntu 26.04 LTS (resolute) to
recover (fresh install ships a current GRUB). Correct order is `apt full-upgrade` (current grub-efi/shim-signed)
FIRST, then dbx.
Even with a current GRUB, applying dbx moves PCR 7 (Secure-Boot-policy measurement) → the TPM (slot 1) refuses
to release the LUKS key → next boot drops to the slot-0 passphrase prompt. VERIFIED end-to-end: proved the
slot-0 typed passphrase first, applied dbx, rebooted to a passphrase prompt, unlocked with slot 0, re-enrolled
`systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` → silent auto-unlock restored.
Gotcha: `cryptsetup open --test-passphrase /dev/sda3` SILENTLY passes via the TPM token (auto-unlocks the tpm2
slot without prompting) — a false safety signal. Force a real typed-passphrase test with
`--disable-external-tokens` (→ "No usable token is available." then prompts; success on slot 0 proves it).
Threat-model lockdown (operator is the adversary): a firmware/dbx update makes the booth need the passphrase to
boot unattended, so operators must not be able to trigger one and must never hold the passphrase. Applied on
park-buzi: `systemctl mask fwupd.service fwupd-refresh.timer` (→ masked/masked, persists), `snap remove
firmware-updater` (remove the GUI; re-check, seeded snaps can re-install), BIOS admin password gates setup
entry, slot-0 passphrase stays escrowed off-machine. Firmware updates are now admin-only/on-site/deliberate.
Recorded in appliance-provisioning.md §4 re-seal runbook + new §4a + gotchas #12/#13.
## [2026-06-30] note | Created disk-os-hardening.md (resolved a long-standing orphan)
`[[disk-os-hardening]]` was referenced from ~18 pages (overview, threat-model, tpm, fleet-deployment,
appliance-provisioning, backup-recovery, index, …) but never written — a dangling wikilink. Wrote it as
the *rationale* page (the why): the five host controls (LUKS FDE, TPM-sealed PCR-7 auto-unlock, Secure
Boot Deployed, GRUB edit-lock, unprivileged-operator) + the firmware/dbx lockdown (§4a cross-ref), each
with its load-bearing nuance, plus the standing caveat that this is the SECONDARY control —
reconciliation over the signed chain is the main anti-fraud event. Commands stay in appliance-provisioning
(the how); this page points there. Updated the index.md line accordingly.
## [2026-06-30] fix | Snapshot content-type bug — every legacy image rendered blank
Symptom: no snapshot showed in the booth modal. Root cause: Hikvision-style cameras return
`Content-Type: image/jpeg; charset="UTF-8"` (a charset param on a binary body = malformed; browsers
refuse to decode an <img> declared that way). Old capture code persisted that raw header into
snapshots.content_type (100 of 101 dev-DB rows); the serve route GET /api/snapshots/:id re-emitted it
verbatim → broken render for every legacy row. Capture was already hardened (encodeForStorage →
clean image/jpeg, fail-soft cleanType), but the serve route trusted the stored value. Fix: route now
runs cleanType(row.contentType) on the way OUT too → bare image/jpeg, un-breaks all legacy rows with
NO data migration. Verified via Playwright: a previously-unrenderable 2560×1440 row now decodes
in-browser; clean + malformed rows both load. Exported cleanType from snapshot.ts + unit tests.
Recorded in entry-exit-points.md. Lesson: normalize a device-supplied content-type on capture AND on
serve (a stored value from an untrusted camera is itself input).
## [2026-06-30] feat | Booth Active-Sessions + pay/exit modal rework
(1) The inline "Open barrier" button on paid-in-grace Active-Session ROWS was removed; the audited
re-pulse now lives only in the modal. Reason: a paid+exited session is open=false, so clicking its
row dead-ended on "already closed" — useless for the exact case (paid, barrier unconfirmed) that
needs a re-pulse. The modal now recognizes closed-within-grace (found && !open && withinGrace) and
shows the session view + Open barrier. Server reopenBarrier guard unchanged (already handled the
closed-in-grace case — the T-397815c0 fix). (2) Active-Sessions rows show a LIVE grace-remaining
countdown badge (exited · M:SS, 1s tick off graceExpiresAt) instead of a static label. (3) Settled
sessions show the ACTUAL sum paid (new SessionLookup.paidMinor, summed across payment events) not a
flat "PAID". (4) A fully-closed (grace-expired) session's modal is no longer a dead-end: it shows a
read-only review view (figures + paid amount + entry/exit snapshot strip) for dispute/audit review,
with no pay/exit/open controls. i18n sq+en parity kept; web build/tests green. Recorded in
booth-exit-flow.md.
## [2026-06-30] feat | DB reset CLI for training/demo (packages/db/scripts/reset-db.mjs)
A site is sometimes run live to train operators/admins; afterwards the demo data must go WITHOUT an
obvious self-serve button (operator must not wipe history). So: a CLI script `pnpm db:reset`, not UI.
Category flags grounded in the table map — --financial (ledger/telemetry/snapshots/subscription
instances/blocklist; keeps users/devices/config/tariffs/plans), --config, --users, --all. Because
shifts/cash/payments all live as event types INSIDE the hash-chained ledger_events, "financial" =
truncate the whole signed ledger back to empty (re-seed starts a new chain under the SAME
EVENT_SIGNING_KEY — key untouched). Two safety gates (decided with user): RESET_ALLOWED=1 env (real
booths never set it) + typed DB-filename confirmation (--yes skips for CI). Single txn + VACUUM;
re-seed admin after --users/--all. On the BOOTH there is no pnpm — only containers — so it runs via
`docker exec` into the server container (node node_modules/@parking/db/scripts/reset-db.mjs,
DATABASE_URL=/data/parking.sqlite); the script ships in the deploy bundle next to the boot migrator
(@parking/db has no `files` allowlist → whole pkg copied). Verified on throwaway dev-DB copies (both
gates refuse correctly; each flag wipes/keeps the right tables; real dev DB never touched). Recorded
in local-dev-workflow.md + appliance-provisioning.md §7d.