diff --git a/ui/services/ws-ticket.test.mjs b/ui/services/ws-ticket.test.mjs
new file mode 100644
index 00000000..00f04176
--- /dev/null
+++ b/ui/services/ws-ticket.test.mjs
@@ -0,0 +1,112 @@
+// Executed tests for the WebSocket ticket helper (ADR-272).
+//
+// Run: node --test ui/services/
+//
+// WHAT THIS IS AND IS NOT.
+// This EXECUTES the module in Node with stubbed `fetch` and `localStorage`. It
+// is strictly more than the `node --check` syntax pass this file replaces, and
+// strictly less than a browser: it does not exercise a real WebSocket upgrade,
+// real cookie handling, or the page wiring. The claim "the UI JavaScript has
+// never been run" is no longer true of this module; "browser-tested" still is
+// not. Both statements matter and neither should be rounded up.
+
+import { test, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+const STORAGE_KEY = 'ruview-api-token';
+
+// --- stubs installed before the module under test is imported ---------------
+let stored = {};
+let fetchCalls = [];
+let fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
+
+globalThis.localStorage = {
+ getItem: (k) => (k in stored ? stored[k] : null),
+ setItem: (k, v) => { stored[k] = String(v); },
+ removeItem: (k) => { delete stored[k]; },
+};
+globalThis.fetch = async (...args) => { fetchCalls.push(args); return fetchImpl(...args); };
+
+const { withWsTicket } = await import('./ws-ticket.js');
+
+beforeEach(() => {
+ stored = {};
+ fetchCalls = [];
+ fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
+});
+
+test('with no stored token the URL is returned unchanged and nothing is fetched', async () => {
+ // Auth is off, so no ticket is needed. Minting one would be a pointless
+ // round-trip on every reconnect.
+ const url = await withWsTicket('ws://host/ws/sensing');
+ assert.equal(url, 'ws://host/ws/sensing');
+ assert.equal(fetchCalls.length, 0);
+});
+
+test('a minted ticket is appended and the bearer is sent only in the header', async () => {
+ stored[STORAGE_KEY] = 'secret-bearer';
+ const url = await withWsTicket('ws://host/ws/sensing');
+
+ assert.equal(url, 'ws://host/ws/sensing?ticket=T');
+ // The long-lived bearer must never reach a URL — only the bounded ticket does.
+ assert.ok(!url.includes('secret-bearer'), `bearer leaked into URL: ${url}`);
+
+ const [path, init] = fetchCalls[0];
+ assert.equal(path, '/api/v1/ws-ticket');
+ assert.equal(init.method, 'POST');
+ assert.equal(init.headers.Authorization, 'Bearer secret-bearer');
+});
+
+test('an existing query string gets & rather than a second ?', async () => {
+ stored[STORAGE_KEY] = 'b';
+ const url = await withWsTicket('ws://host/ws/sensing?foo=1');
+ assert.equal(url, 'ws://host/ws/sensing?foo=1&ticket=T');
+});
+
+test('the ticket value is URL-encoded', async () => {
+ stored[STORAGE_KEY] = 'b';
+ fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'a+b/c=d' }) });
+ const url = await withWsTicket('ws://host/ws/sensing');
+ assert.ok(url.endsWith('ticket=a%2Bb%2Fc%3Dd'), url);
+});
+
+test('404 means a server predating ADR-272, so connect without a ticket', async () => {
+ // That server still exempts WebSockets, so an unticketed connect is correct.
+ // This is what lets one UI work against both old and new servers, which is
+ // what makes the legacy escape hatch removable rather than permanent.
+ stored[STORAGE_KEY] = 'b';
+ fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
+ assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
+});
+
+test('a 503 does not append a ticket and does not throw', async () => {
+ // Ticket store exhausted. The socket attempt will fail on its own and the
+ // caller's reconnect logic handles it; throwing here would duplicate that.
+ stored[STORAGE_KEY] = 'b';
+ fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
+ assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
+});
+
+test('a network failure is swallowed rather than breaking the connect path', async () => {
+ stored[STORAGE_KEY] = 'b';
+ fetchImpl = async () => { throw new Error('offline'); };
+ assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
+});
+
+test('a 200 with no ticket field yields no ticket', async () => {
+ stored[STORAGE_KEY] = 'b';
+ fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({}) });
+ assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
+});
+
+test('a fresh ticket is minted per call and never reused', async () => {
+ // Tickets are single-use and expire in ~30s, so caching one across reconnects
+ // fails on the second attempt.
+ stored[STORAGE_KEY] = 'b';
+ let n = 0;
+ fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: `T${++n}` }) });
+
+ assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T1');
+ assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T2');
+ assert.equal(fetchCalls.length, 2, 'each connect attempt must mint its own');
+});
diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js
index afae95eb..33c91212 100644
--- a/ui/utils/quick-settings.js
+++ b/ui/utils/quick-settings.js
@@ -74,6 +74,16 @@ export class QuickSettings {
+
+
Cognitum Account
+
+ Checking\u2026
+
+
+
+
+
+
API Access
@@ -233,3 +243,66 @@ export class QuickSettings {
this.panel?.remove();
}
}
+
+// ---- Cognitum browser sign-in (ADR-271) -------------------------------------
+//
+// `/oauth/status` is intentionally UNGATED: a signed-out browser cannot ask a
+// gated endpoint whether sign-in is available. It returns capability flags and,
+// when a session exists, who it belongs to — never a credential.
+//
+// Sign-in is a full-page navigation, not fetch(): the server replies 302 to
+// auth.cognitum.one, and the browser must follow it and carry the transaction
+// cookie. An XHR would follow the redirect invisibly and land nowhere useful.
+export async function refreshSignInPanel(root = document) {
+ const status = root.querySelector('#qs-signin-status');
+ const signIn = root.querySelector('#qs-signin');
+ const signOut = root.querySelector('#qs-signout');
+ if (!status || !signIn || !signOut) return null;
+
+ let info;
+ try {
+ const resp = await fetch('/oauth/status', { credentials: 'same-origin' });
+ // 404 = a server predating ADR-271. Say so plainly rather than offering a
+ // button that will 404.
+ if (resp.status === 404) {
+ status.textContent = 'This server does not support Cognitum sign-in.';
+ signIn.hidden = true;
+ signOut.hidden = true;
+ return null;
+ }
+ if (!resp.ok) throw new Error(`status ${resp.status}`);
+ info = await resp.json();
+ } catch (err) {
+ status.textContent = `Could not reach the server (${err.message}).`;
+ signIn.hidden = true;
+ signOut.hidden = true;
+ return null;
+ }
+
+ if (info.signed_in) {
+ status.textContent = `Signed in${info.account ? ` as ${info.account}` : ''}${
+ info.scope ? ` \u2014 ${info.scope}` : ''
+ }`;
+ signIn.hidden = true;
+ signOut.hidden = false;
+ } else if (info.browser_signin) {
+ status.textContent = info.auth_required
+ ? 'This server requires sign-in.'
+ : 'Optional: sign in to use your Cognitum account.';
+ signIn.hidden = false;
+ signOut.hidden = true;
+ } else if (info.auth_required) {
+ // Auth is on but OAuth is not — the static-token panel below is the path.
+ status.textContent = 'This server uses a shared API token (see API Access below).';
+ signIn.hidden = true;
+ signOut.hidden = true;
+ } else {
+ status.textContent = 'This server does not require sign-in.';
+ signIn.hidden = true;
+ signOut.hidden = true;
+ }
+
+ signIn.onclick = () => { window.location.href = '/oauth/start'; };
+ signOut.onclick = () => { window.location.href = '/oauth/logout'; };
+ return info;
+}
diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs
index f5fe5ad1..d2663668 100644
--- a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs
+++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs
@@ -31,6 +31,7 @@
//! arrived over TLS. Every other attribute — `HttpOnly`, `SameSite=Lax`,
//! `Path=/` — matches, and the signature is what actually protects the value.
+use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
@@ -155,13 +156,86 @@ pub enum SessionError {
InvalidToken(String),
}
-fn secret() -> Result {
- std::env::var(SESSION_SECRET_ENV)
+/// Process-wide secret, resolved once.
+static SECRET: std::sync::OnceLock