// 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} */ export async function withWsTicket(url) { const ticket = await mintTicket(); if (!ticket) return url; const sep = url.includes('?') ? '&' : '?'; return `${url}${sep}ticket=${encodeURIComponent(ticket)}`; }