diff --git a/apps/web/src/lib/use-live-feed.ts b/apps/web/src/lib/use-live-feed.ts index 4098d38..940435a 100644 --- a/apps/web/src/lib/use-live-feed.ts +++ b/apps/web/src/lib/use-live-feed.ts @@ -23,7 +23,14 @@ type WsMessage = | { 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 { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } = useLiveStore(); @@ -34,6 +41,10 @@ export function useLiveFeed(): void { const closedRef = useRef(false); useEffect(() => { + if (!enabled) { + setStatus("closed"); + return; + } closedRef.current = false; const connect = () => { @@ -117,7 +128,8 @@ export function useLiveFeed(): void { sockRef.current?.close(); 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 - }, []); + }, [enabled]); } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 08cbabe..0c99fa4 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -448,11 +448,17 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri function RootLayout() { const { user, setUser } = rootRoute.useRouteContext(); 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 // the permission its screen needs (the route guards enforce the same server-side). 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 (
@@ -489,11 +495,11 @@ function RootLayout() { show("shift:read")) && }
- {user && } + {user && show("shift:read") && } {user && } {user && } {user && } - + {canWatch && } {user && ( - {/* Fixed device-status footer — relays, readers, cameras, printers. */} - {user && } + {/* Fixed device-status footer — relays, readers, cameras, printers. Its REST + 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") && }
); } diff --git a/wiki/concepts/validation-discounts.md b/wiki/concepts/validation-discounts.md index a39396b..21c2a52 100644 --- a/wiki/concepts/validation-discounts.md +++ b/wiki/concepts/validation-discounts.md @@ -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 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 - 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). Feed label `VALIDIM`/`VALIDATION`. RolesManager picks the new resource up generically. - **Verified**: 8 route-level integration tests (guards, signed events, money cycle, void locks,