fix(sensing-server): exempt /api/v1/stream/pose WS from bearer auth; add UI token field

Browsers cannot attach an Authorization header to a WebSocket upgrade,
so with RUVIEW_API_TOKEN set the Live Demo pose stream at
/api/v1/stream/pose always failed with 401 — the same reason
/ws/sensing is already exempted (see bearer_auth module docs). Adds a
narrow EXEMPT_PATHS list plus a regression test that the exemption
does not leak to other /api/v1/* paths. Query-string tokens remain
rejected (CWE-598 test untouched).

Also adds an 'API Access' bearer-token field to the QuickSettings
panel: ui/services/api.service.js had setAuthToken() but nothing ever
called it, so enabling RUVIEW_API_TOKEN broke every /api/v1/* call
from the bundled dashboard. The token is stored in localStorage and
applied before the first request.

Fixes #1310

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
erichkusuki
2026-07-11 13:32:02 +02:00
parent e6f26e9ac9
commit 2ddb6a7b02
3 changed files with 101 additions and 1 deletions
+55
View File
@@ -1,6 +1,10 @@
// Quick Settings Panel - Centralized configuration for all UI features
// Accessible via gear icon in header
import { apiService } from '../services/api.service.js';
const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
export class QuickSettings {
constructor(app) {
this.app = app;
@@ -10,10 +14,21 @@ export class QuickSettings {
}
init() {
this.applyStoredApiToken();
this.createButton();
this.createPanel();
}
// Apply a previously-saved bearer token to apiService as early as
// possible, before any tab's REST calls fire. The server only ever
// checks the `Authorization: Bearer` header (see bearer_auth.rs) — this
// intentionally never puts the token in a URL query string.
applyStoredApiToken() {
let token = null;
try { token = localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
if (token) apiService.setAuthToken(token);
}
createButton() {
this.button = document.createElement('button');
this.button.className = 'settings-gear';
@@ -70,6 +85,18 @@ export class QuickSettings {
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">API Access</div>
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
<span>Bearer token (set only if the server enforces RUVIEW_API_TOKEN)</span>
<input type="password" id="qs-api-token" class="qs-text-input" placeholder="Paste token..." autocomplete="off" style="width: 100%; box-sizing: border-box;">
<div style="display: flex; gap: 8px;">
<button class="qs-btn" id="qs-api-token-save">Save & Apply</button>
<button class="qs-btn-danger" id="qs-api-token-clear">Clear</button>
</div>
<span id="qs-api-token-status" style="font-size: 0.85em; opacity: 0.75;"></span>
</div>
</div>
<div class="qs-section">
<div class="qs-section-title">Data</div>
<div class="qs-row">
@@ -112,6 +139,30 @@ export class QuickSettings {
}
});
this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => {
const input = this.panel.querySelector('#qs-api-token');
const status = this.panel.querySelector('#qs-api-token-status');
const token = input.value.trim();
if (!token) {
status.textContent = 'Enter a token first, or use Clear to remove one.';
return;
}
try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ }
apiService.setAuthToken(token);
status.textContent = 'Token saved and applied. Reloading...';
setTimeout(() => window.location.reload(), 600);
});
this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => {
const input = this.panel.querySelector('#qs-api-token');
const status = this.panel.querySelector('#qs-api-token-status');
try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
apiService.setAuthToken(null);
input.value = '';
status.textContent = 'Token cleared. Reloading...';
setTimeout(() => window.location.reload(), 600);
});
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
try {
localStorage.clear();
@@ -154,6 +205,10 @@ export class QuickSettings {
if (this.getSetting('compact')) {
document.body.classList.add('compact-mode');
}
const status = this.panel.querySelector('#qs-api-token-status');
let hasToken = false;
try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).';
}
prefersReducedMotion() {