feat(dashboard-auth): SPA WS auth — getWsTicket() + buildWsAuthParam()
Phase 5 task 5.3. The dashboard's three WS-using surfaces (ChatPage, gatewayClient, ChatSidebar) previously hardcoded ?token=<session>. In gated mode the server rejects that path; the SPA must mint a single-use ticket via POST /api/auth/ws-ticket and pass ?ticket= on the upgrade. web/src/lib/api.ts: adds getWsTicket() (POST /api/auth/ws-ticket with credentials: 'include') and buildWsAuthParam() — a helper that returns ['ticket', <minted>] in gated mode and ['token', <session>] in loopback. Window.__HERMES_AUTH_REQUIRED__ is read from the server-injected bootstrap script and toggles the path. Documented as the bridge from cookie auth (REST) to WS auth. web/src/pages/ChatPage.tsx: buildWsUrl() now takes an [authName, authValue] pair instead of a bare token. The WS construct is wrapped in an IIFE so the outer effect can stay synchronous (the cleanup returns the effect's disposer at top level). onDataDisposable + onResizeDisposable hoisted to `let` bindings the cleanup closes over. web/src/lib/gatewayClient.ts: connect() branches on window.__HERMES_AUTH_REQUIRED__ before opening /api/ws. Explicit token overrides win (test-only path); otherwise gated → fetch ticket, loopback → use injected session token. web/src/components/ChatSidebar.tsx: events-feed WS opens through the same IIFE pattern as ChatPage. The ws local is hoisted so the cleanup's ws?.close() works after the async mint resolves. Server side already injects window.__HERMES_AUTH_REQUIRED__ in _serve_index (Phase 3.5).
This commit is contained in:
+38
-27
@@ -24,7 +24,7 @@ import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { HERMES_BASE_PATH } from "@/lib/api";
|
||||
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy, PanelRight, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -38,12 +38,15 @@ import { api } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
function buildWsUrl(
|
||||
token: string,
|
||||
authParam: [string, string],
|
||||
resume: string | null,
|
||||
channel: string,
|
||||
): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({ token, channel });
|
||||
// ``authParam`` is ``["token", <session>]`` in loopback mode and
|
||||
// ``["ticket", <minted>]`` in gated mode. The server-side helper
|
||||
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
|
||||
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
|
||||
if (resume) qs.set("resume", resume);
|
||||
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
|
||||
}
|
||||
@@ -544,15 +547,22 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
});
|
||||
});
|
||||
|
||||
// WebSocket
|
||||
const url = buildWsUrl(token, resumeParam, channel);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
// Suppress banner/terminal side-effects when cleanup() calls `ws.close()`
|
||||
// (React StrictMode remount, route change) so we never write to a
|
||||
// disposed xterm or setState on an unmounted tree.
|
||||
// WebSocket. In gated mode (``window.__HERMES_AUTH_REQUIRED__``) this
|
||||
// awaits a single-use ticket via /api/auth/ws-ticket before opening;
|
||||
// in loopback mode it resolves synchronously against the injected
|
||||
// session token. The IIFE keeps the outer effect synchronous so its
|
||||
// ``return cleanup`` stays at the top level; handlers + disposables
|
||||
// are hoisted to ``let`` bindings the cleanup closes over.
|
||||
let unmounting = false;
|
||||
let onDataDisposable: { dispose(): void } | null = null;
|
||||
let onResizeDisposable: { dispose(): void } | null = null;
|
||||
void (async () => {
|
||||
const authParam = await buildWsAuthParam();
|
||||
if (unmounting) return;
|
||||
const url = buildWsUrl(authParam, resumeParam, channel);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setBanner(null);
|
||||
@@ -605,31 +615,32 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
// mouse reporting, so we drop SGR mouse reports entirely instead of
|
||||
// forwarding them into Hermes. Keyboard input, paste, and resize still
|
||||
// behave normally.
|
||||
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
|
||||
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
||||
const onDataDisposable = term.onData((data) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
|
||||
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
||||
onDataDisposable = term.onData((data) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
if (SGR_MOUSE_RE.test(data)) {
|
||||
return;
|
||||
}
|
||||
if (SGR_MOUSE_RE.test(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(data);
|
||||
});
|
||||
ws.send(data);
|
||||
});
|
||||
|
||||
const onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
|
||||
}
|
||||
});
|
||||
onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
term.focus();
|
||||
|
||||
return () => {
|
||||
unmounting = true;
|
||||
syncMetricsRef.current = null;
|
||||
onDataDisposable.dispose();
|
||||
onResizeDisposable.dispose();
|
||||
onDataDisposable?.dispose();
|
||||
onResizeDisposable?.dispose();
|
||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
|
||||
window.visualViewport?.removeEventListener(
|
||||
|
||||
Reference in New Issue
Block a user