Compare commits
5 Commits
9c6741a485
...
266e9b0027
| Author | SHA1 | Date | |
|---|---|---|---|
| 266e9b0027 | |||
| d92b8d1e6a | |||
| 61de1fe772 | |||
| cfac14e09e | |||
| 1b86750b0d |
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 "—";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user