feat(ui): Sign in with Cognitum, zero-config session secret, executed JS tests

Three loose ends from the browser sign-in work.

--- 1. The last mile: a button ---

The server endpoints existed but nothing in ui/ linked to them, so the feature
was unreachable. QuickSettings gains a "Cognitum Account" panel that renders
from `GET /oauth/status` and offers Sign in / Sign out.

`/oauth/status` is a new endpoint and is deliberately UNGATED — a signed-OUT
browser cannot ask a gated API whether sign-in is available. It returns only
capability flags and, when a session exists, who it belongs to. Never a
credential.

The panel distinguishes four states rather than showing a button that might
404: signed in; sign-in available; auth on but OAuth off (points at the
existing static-token panel); no auth required. A 404 from /oauth/status means
a server predating this work and says so plainly.

Sign-in is a full-page navigation, not fetch(): the server replies 302 and the
browser must follow it carrying the transaction cookie. An XHR would follow the
redirect invisibly and land nowhere.

--- 2. RUVIEW_SESSION_SECRET no longer required ---

Previously `/oauth/start` returned 503 unless an operator invented a secret —
a footgun, since they set RUVIEW_OAUTH_ISSUER, expect sign-in, and get a 503
naming an env var they have never heard of.

Now resolved in order: env var, then `<data_dir>/session-secret`, then generate
one and persist it 0600 (temp file, chmod before rename — the discipline used
for the CLI's credentials). Persisted rather than in-memory so a restart does
not silently sign everyone out.

The env var still wins, which is what a multi-instance deployment needs: several
servers must share a secret or a session issued by one is rejected by the next.
If the file cannot be written we log the reason and continue with an in-memory
secret rather than refusing sign-in outright.

Verified with NO configuration at all: secret generated, file mode 0600,
/oauth/start returns 302, /oauth/status reports browser_signin: true.

--- 3. The JavaScript is now executed by tests ---

`ui/services/ws-ticket.test.mjs`, 9 tests, node:test built-in — no new
dependency and no package.json needed. Run: `node --test ui/services/`.

Covers: no token means no fetch and an unchanged URL; the bearer reaches the
Authorization header and NEVER the URL; `?` vs `&` when a query already exists;
ticket URL-encoding; 404 treated as a pre-ADR-272 server (the property that
lets one UI work against old and new servers, and therefore lets the legacy
escape hatch be removed later); 503 and network failure swallowed rather than
breaking the connect path; and that a fresh ticket is minted per call, since
tickets are single-use and caching one fails on the second reconnect.

STATED PRECISELY, because the distinction matters: this EXECUTES the module in
Node with stubbed fetch/localStorage. It is more than the `node --check` it
replaces and less than a browser — no real WebSocket upgrade, no real cookies,
no page wiring. "The UI JavaScript has never been run" is no longer true of
this module. "Browser-tested" still is not.

Tests: 547 sensing-server lib, 5 wiring integration, 87 ruview-auth, 9 JS.

Co-Authored-By: Ruflo & AQE
This commit is contained in:
Dragan Spiridonov
2026-07-23 10:07:49 +02:00
parent 347698b67c
commit 9299a3b137
4 changed files with 293 additions and 2 deletions
+73
View File
@@ -74,6 +74,16 @@ export class QuickSettings {
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">Cognitum Account</div>
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
<span id="qs-signin-status" style="font-size: 0.9em; opacity: 0.85;">Checking\u2026</span>
<div style="display: flex; gap: 8px;">
<button class="qs-btn" id="qs-signin" hidden>Sign in with Cognitum</button>
<button class="qs-btn-danger" id="qs-signout" hidden>Sign out</button>
</div>
</div>
</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;">
@@ -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;
}