Files
ruvnet--RuView/ui/services/ws-ticket.js
T
Dragan Spiridonov eb68e07a2c feat(ui): fetch WebSocket tickets, prefix-match WS paths, and write ADR-272
Completes ADR-272. Three parts.

1. PREFIX MATCHING, not an allowlist. Anything under `/ws/` is a WebSocket path,
   plus `/api/v1/stream/pose` which lives outside it. An allowlist means every
   WebSocket route added later ships ungated until someone remembers to extend
   it — the same bug reintroduced on a delay. Not hypothetical:
   `/ws/train/progress` (ADR-186, arriving with PR #1387) is ALREADY referenced
   by ui/services/training.service.js and would have shipped unauthenticated.
   Pinned by a test that asserts it is gated before it exists.

2. UI wiring. A shared `withWsTicket()` helper mints a ticket immediately before
   each connection attempt — never cached, because a ticket is single-use and
   expires in seconds, so reusing one across reconnects fails on the second
   attempt. Wired into the three sites that open gated sockets:
   sensing.service.js, websocket-client.js, observatory/js/main.js.

   It degrades in both directions on purpose: no stored token means auth is off
   and no ticket is needed; a 404 from /api/v1/ws-ticket means a server
   predating this ADR, which still exempts WebSockets, so connecting without a
   ticket is correct there. The same UI therefore works against old and new
   servers, which is what makes the escape hatch removable later rather than
   permanent.

   The long-lived bearer token is still never put in a URL — only the ticket is.

3. ADR-272 itself. Previously cited in five places without existing; the same
   dangling-reference mistake made with ADR-271 earlier today, so it is written
   before this lands rather than after someone notices.

   It records the measured before/after, why a credential in a URL is
   acceptable here specifically (single use, seconds-long, not the credential),
   why the escape hatch exists and why it is deliberately uncomfortable, and
   what is deliberately NOT done — including that /health/metrics stays ungated,
   with the caveat that this should be revisited if metrics ever carry
   occupancy-derived values, since that would make them sensing data wearing an
   ops label.

Tests: 526 sensing-server (4 new path-matching), 82 ruview-auth. JS
syntax-checked with `node --check`; there is no UI test suite to extend.

Co-Authored-By: Ruflo & AQE
2026-07-22 19:15:20 +02:00

78 lines
2.8 KiB
JavaScript

// Single-use WebSocket tickets (ADR-272).
//
// A browser's WebSocket constructor cannot set an `Authorization` header on the
// upgrade request. That used to mean the sensing WebSocket stayed reachable
// with no credential even when the server had auth switched on — the REST
// control plane was locked while the live sensing stream was open.
//
// The server now gates `/ws/*` and `/api/v1/stream/pose`. Browsers exchange
// their stored bearer token at `POST /api/v1/ws-ticket` — an ordinary request,
// where headers DO work — for a short-lived, single-use ticket, and pass that
// as `?ticket=` on the socket URL.
//
// Why a token in a URL is acceptable here when it normally is not: the ticket
// is consumed on first use, lives ~30 seconds, and authorizes one WebSocket
// and nothing else. It cannot be replayed against /api/v1/*. The long-lived
// bearer token is still never put in a URL.
import { API_TOKEN_STORAGE_KEY } from './api.service.js';
function storedToken() {
try {
return localStorage.getItem(API_TOKEN_STORAGE_KEY) || null;
} catch {
// Private browsing / storage disabled — treat as "no token configured".
return null;
}
}
/**
* Mint a ticket. Returns null when no token is configured (auth is off, so no
* ticket is needed) or when the server does not offer the endpoint.
*/
async function mintTicket() {
const token = storedToken();
if (!token) return null;
try {
const resp = await fetch('/api/v1/ws-ticket', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
// 404 means a server predating ADR-272: it still exempts WebSockets, so
// connecting without a ticket is correct there. Treated as "no ticket
// needed" rather than as an error, so the UI works against both.
if (resp.status === 404) return null;
if (!resp.ok) {
console.warn('[ws-ticket] mint failed:', resp.status);
return null;
}
const body = await resp.json();
return body.ticket || null;
} catch (err) {
// Offline, or the server is down. The socket attempt will fail on its own
// and the caller's reconnect logic handles it; failing loudly here would
// just duplicate that.
console.warn('[ws-ticket] mint error:', err.message);
return null;
}
}
/**
* Return `url` with a freshly minted ticket appended, or unchanged when no
* ticket is available or needed.
*
* Always call this immediately before opening the socket — a ticket expires in
* seconds and is valid exactly once, so one must never be cached or reused
* across reconnects.
*
* @param {string} url ws:// or wss:// URL
* @returns {Promise<string>}
*/
export async function withWsTicket(url) {
const ticket = await mintTicket();
if (!ticket) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}ticket=${encodeURIComponent(ticket)}`;
}