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 (