fix(web): permission-degrade the app shell for merchant-only users
Build desktop / desktop (push) Successful in 4m51s
Build & push images / images (push) Successful in 3m8s
CI / check (push) Successful in 52s

A user whose role has only validation:create (the bar/lavazh validator) made
the shell misbehave: useLiveFeed() connected /api/ws unconditionally, the
server's report:read guard 403'd the upgrade, and the capped-backoff
reconnect hammered it forever — a 403 in the server log every few seconds.
Gate the socket on report:read (mirrors routes/ws.ts WATCH_PERMISSION) and
render StatusDot / ShiftButton / DeviceFooter only with their backing
permissions (report:read / shift:read / device:read), so a merchant's shell
is just the nav + their /validate screen, with zero doomed requests.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
This commit is contained in:
2026-07-13 20:12:43 +02:00
parent 0ed43239c3
commit 19dff97c74
3 changed files with 34 additions and 10 deletions
+15 -3
View File
@@ -23,7 +23,14 @@ type WsMessage =
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } }; | { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
export function useLiveFeed(): void { /**
* @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's
* WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator
* with only `validation:create`) must not attempt the socket at all: the server
* 403s the upgrade and the capped-backoff reconnect would otherwise hammer it
* forever, filling the server log with a 403 every few seconds.
*/
export function useLiveFeed(enabled: boolean = true): void {
const qc = useQueryClient(); const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } = const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
useLiveStore(); useLiveStore();
@@ -34,6 +41,10 @@ export function useLiveFeed(): void {
const closedRef = useRef(false); const closedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!enabled) {
setStatus("closed");
return;
}
closedRef.current = false; closedRef.current = false;
const connect = () => { const connect = () => {
@@ -117,7 +128,8 @@ export function useLiveFeed(): void {
sockRef.current?.close(); sockRef.current?.close();
sockRef.current = null; sockRef.current = null;
}; };
// qc / store setters are stable; run once on mount. // qc / store setters are stable; re-run only if the permission gate flips
// (login as a different role without a full reload).
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [enabled]);
} }
+14 -6
View File
@@ -448,11 +448,17 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri
function RootLayout() { function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext(); const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation(); const { t } = useTranslation();
// One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed();
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants // Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
// the permission its screen needs (the route guards enforce the same server-side). // the permission its screen needs (the route guards enforce the same server-side).
const show = (perm: Permission) => can(user, perm); const show = (perm: Permission) => can(user, perm);
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
// for roles the server would accept (routes/ws.ts gates on report:read). A
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
// on backoff forever and spam the server log. Same rule for the widgets that feed
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
// DeviceFooter → device:read).
const canWatch = show("report:read");
useLiveFeed(canWatch);
return ( return (
<div className="flex h-screen flex-col bg-term-bg text-term-text"> <div className="flex h-screen flex-col bg-term-bg text-term-text">
@@ -489,11 +495,11 @@ function RootLayout() {
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />} show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{user && <ShiftButton />} {user && show("shift:read") && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />} {user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />} {user && <ThemeToggle user={user} setUser={setUser} />}
{user && <FontScaleToggle user={user} setUser={setUser} />} {user && <FontScaleToggle user={user} setUser={setUser} />}
<StatusDot /> {canWatch && <StatusDot />}
{user && ( {user && (
<Link <Link
to="/profile" to="/profile"
@@ -518,8 +524,10 @@ function RootLayout() {
<main className="min-h-0 flex-1 overflow-auto p-3"> <main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet /> <Outlet />
</main> </main>
{/* Fixed device-status footer — relays, readers, cameras, printers. */} {/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
{user && <DeviceFooter />} seed needs device:read (and its live updates ride the report:read WS), so
it's hidden for roles without device visibility (e.g. merchant validators). */}
{user && show("device:read") && <DeviceFooter />}
</div> </div>
); );
} }
+5 -1
View File
@@ -100,7 +100,11 @@ priced at booth check-in, inside the normal walk-back-grace flow).
both; mode/params/caps/receipt-label/bound-users). `/validate` (`ValidateScreen.tsx`) is the both; mode/params/caps/receipt-label/bound-users). `/validate` (`ValidateScreen.tsx`) is the
merchant's whole surface (scan/key → apply → void own unused), mobile-friendly, autofocused merchant's whole surface (scan/key → apply → void own unused), mobile-friendly, autofocused
input works with HID scanners; merchant-only users (no `session:read`) land there on login and input works with HID scanners; merchant-only users (no `session:read`) land there on login and
the permission-gated nav shows them nothing else. Booth pay modal shows gross → lines → net; the permission-gated nav shows them nothing else. The app SHELL also degrades by permission
(2026-07-13 follow-up): the live-feed WebSocket connects only with `report:read` (the server's
WS guard — a merchant's socket would 403 and the capped-backoff reconnect would spam the server
log forever), and the StatusDot / ShiftButton / DeviceFooter widgets render only with their
backing permissions (`report:read` / `shift:read` / `device:read`). Booth pay modal shows gross → lines → net;
the zero-net comp settles through the normal pay path (grace starts, voucher/exit unchanged). the zero-net comp settles through the normal pay path (grace starts, voucher/exit unchanged).
Feed label `VALIDIM`/`VALIDATION`. RolesManager picks the new resource up generically. Feed label `VALIDIM`/`VALIDATION`. RolesManager picks the new resource up generically.
- **Verified**: 8 route-level integration tests (guards, signed events, money cycle, void locks, - **Verified**: 8 route-level integration tests (guards, signed events, money cycle, void locks,