openvibers/openvibe.network Commits

Navbar: resolve session from ov_token via /api/auth/me; host-aware sign-in/out links; token refresh

OP OpenVibers committed 17 Aug 2026 · 3ebe2c6367 · 3 files, +537 −11


Changes

ddiff --git a/package.json b/package.json
iindex 76f9fee1..361464fe 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/packages/openvibe-shared/navbar.js b/packages/openvibe-shared/navbar.js
iindex 8eecb7d6..5c460922 100644
---- a/packages/openvibe-shared/navbar.js
++++ b/packages/openvibe-shared/navbar.js
@@@ -3,12 +3,17 @@
// Consistent top bar across all services with logo, navigation,
// notification bell, account switcher, and theme-aware styling.
// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
++// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
++// endpoint returning { user } — used only when no ov_token exists),
++// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
++// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
++// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
// ═══════════════════════════════════════════════════════════════
(function (root) {
'use strict';
-- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null };
++ let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };
let _navEl = null;
function injectStyles() {
@@@ -429,6 +434,200 @@
try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
}
++ // ─── SSO auth resolution ───────────────────────────────────
++ // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,
++ // and Domain=.openvibe.tools on the tools apex + every tool subdomain.
++ // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,
++ // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,
++ // img., yt., docs., food., …) do NOT mount /auth/* — they only read the
++ // shared cookie — so sign-in/out always goes through the gateway apex.
++ // Detection order: ov_token cookie → localStorage → page-provided token →
++ // app-specific session endpoint (opt-in via config.sessionUrl).
++ const TOOLS_APEX = 'openvibe.tools';
++ const TOKEN_KEY = 'ov_token';
++
++ function currentHost() {
++ return (typeof location !== 'undefined' && location.hostname) || '';
++ }
++
++ function onToolsDomain(host) {
++ const h = host !== undefined ? host : currentHost();
++ return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX);
++ }
++
++ function onNetworkDomain(host) {
++ const h = host !== undefined ? host : currentHost();
++ try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; }
++ }
++
++ function parseCookieToken(cookieStr) {
++ const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/);
++ if (!m || !m[1]) return null;
++ try { return decodeURIComponent(m[1]); } catch { return m[1]; }
++ }
++
++ function getCookieToken() {
++ if (typeof document === 'undefined') return null;
++ return parseCookieToken(document.cookie);
++ }
++
++ function getStoredToken() {
++ try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; }
++ }
++
++ function resolveToken() {
++ return getCookieToken() || getStoredToken() || _config.token || null;
++ }
++
++ /** Where "Sign In" goes on this host. */
++ function resolveLoginHref(host, returnUrl) {
++ if (_config.loginUrl) return _config.loginUrl;
++ if (onToolsDomain(host)) {
++ // Every *.openvibe.tools host signs in through the gateway apex: it
++ // holds the OAuth state cookie + redirect_uri (both apex-host-only)
++ // and sets ov_token with Domain=.openvibe.tools, so the session
++ // reaches all satellites. A host-local /auth/login would 404 on
++ // satellites and break the OAuth state check on gateway subdomains.
++ return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;
++ }
++ if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
++ return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`;
++ }
++
++ function setAuthCookie(token) {
++ if (typeof document === 'undefined') return;
++ const maxAge = 24 * 60 * 60; // matches the 24h access JWT
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`;
++ } else {
++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
++ }
++ }
++
++ function clearAuthState() {
++ if (typeof document !== 'undefined') {
++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`;
++ }
++ }
++ try {
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.removeItem('openvibe_anon_token');
++ localStorage.removeItem('openvibe_active_account');
++ } catch { /* storage unavailable */ }
++ }
++
++ function persistToken(token) {
++ try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ }
++ setAuthCookie(token);
++ }
++
++ /** GET {apiBase}/api/auth/me with the Bearer token. */
++ async function fetchMe(token) {
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/me`, {
++ headers: { Authorization: `Bearer ${token}` },
++ });
++ if (res.ok) {
++ const data = await res.json();
++ return { ok: true, user: (data && (data.user || data)) || null };
++ }
++ return { ok: false, unauthorized: res.status === 401 || res.status === 403 };
++ } catch {
++ return { ok: false, unauthorized: false };
++ }
++ }
++
++ /** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */
++ async function gatewayRefresh() {
++ try {
++ const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' });
++ if (!res.ok) return null;
++ const data = await res.json();
++ return data && data.token ? data : null;
++ } catch { return null; }
++ }
++
++ /** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */
++ async function networkRefresh(token) {
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${token}` },
++ });
++ if (!res.ok) return null;
++ const data = await res.json();
++ return data && data.token ? data : null;
++ } catch { return null; }
++ }
++
++ /**
++ * Resolve the signed-in user from the shared SSO state.
++ * With a token: validate it against the Network. If it is rejected, make
++ * ONE refresh attempt (same-origin gateway refresh first on tools hosts,
++ * then the Network's grace refresh) before giving up — never leave a
++ * stale "Sign In" when a recoverable session exists.
++ */
++ async function resolveSessionUser() {
++ const token = resolveToken();
++ if (token) {
++ const me = await fetchMe(token);
++ if (me.ok && me.user) return { user: me.user, token };
++ if (me.unauthorized) {
++ if (onToolsDomain()) {
++ const r = await gatewayRefresh();
++ if (r) {
++ const user = r.user || (await fetchMe(r.token)).user;
++ if (user) { persistToken(r.token); return { user, token: r.token }; }
++ }
++ }
++ const n = await networkRefresh(token);
++ if (n) {
++ const user = n.user || (await fetchMe(n.token)).user;
++ if (user) { persistToken(n.token); return { user, token: n.token }; }
++ }
++ }
++ return null;
++ }
++ // No token anywhere — ask the page's own session endpoint if it has one
++ if (_config.sessionUrl) {
++ try {
++ const res = await fetch(_config.sessionUrl, { credentials: 'include' });
++ if (res.ok) {
++ const data = await res.json();
++ const user = (data && (data.user || data)) || null;
++ if (user && (user.username || user.id)) return { user, token: null };
++ }
++ } catch { /* signed out */ }
++ }
++ return null;
++ }
++
++ let _authInFlight = null;
++
++ function refreshAuthState() {
++ if (_authInFlight) return _authInFlight;
++ _authInFlight = resolveSessionUser()
++ .then((session) => {
++ _authInFlight = null;
++ if (session && session.user) {
++ _config.user = session.user;
++ if (session.token) _config.token = session.token;
++ render();
++ }
++ try {
++ document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
++ detail: { user: session ? session.user : null, token: session ? session.token : null },
++ }));
++ } catch { /* non-DOM environment */ }
++ return session ? session.user : null;
++ })
++ .catch(() => { _authInFlight = null; return null; });
++ return _authInFlight;
++ }
++
function escapeAttr(value) {
return String(value ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
@@@ -497,8 +696,8 @@
const u = _config.user;
const accounts = getAccounts();
const isAnon = u && u.is_anon;
-- const loginHref = `https://openvibe.network/login?return=${encodeURIComponent(window.location.href)}`;
-- const addAccountHref = `https://openvibe.network/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
++ const loginHref = resolveLoginHref(currentHost(), window.location.href);
++ const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
nav.innerHTML = `
<a class="openvibe-navbar-brand" href="${brandHref}">
@@@ -588,12 +787,16 @@
dropdown.classList.remove('open');
if (_config.onLogout) _config.onLogout();
else {
-- document.cookie = 'ov_token=;path=/;max-age=0';
-- document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools';
-- localStorage.removeItem('ov_token');
-- localStorage.removeItem('openvibe_anon_token');
-- localStorage.removeItem('openvibe_active_account');
-- window.location.reload();
++ clearAuthState();
++ _config.user = null;
++ _config.token = null;
++ if (onToolsDomain()) {
++ // The gateway also clears the Domain=.openvibe.tools cookie
++ // and the httpOnly refresh cookie, then sends us back here.
++ window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;
++ } else {
++ window.location.reload();
++ }
}
});
} else {
@@@ -621,9 +824,19 @@
init(opts = {}) {
Object.assign(_config, opts);
injectStyles();
-- return render();
++ const el = render();
++ // Pages that hand us a resolved user (openvibe.network, the tools
++ // gateway hub) keep full control. Everyone else — pages that pass
++ // only a token, or nothing at all — gets the user resolved from
++ // the shared SSO state (ov_token cookie / localStorage / optional
++ // sessionUrl) and the navbar re-renders when it arrives.
++ if (!_config.user) refreshAuthState();
++ return el;
},
++ /** Re-resolve the signed-in user from the shared SSO state. */
++ refreshAuth() { return refreshAuthState(); },
++
/** Update user (after account switch). */
setUser(user) {
_config.user = user;
@@@ -643,6 +856,18 @@
_navEl?.remove();
_navEl = null;
},
++
++ /** Internal auth helpers — exposed for tests, not a public API. */
++ _auth: {
++ parseCookieToken,
++ onToolsDomain,
++ onNetworkDomain,
++ resolveLoginHref,
++ resolveToken,
++ resolveSessionUser,
++ clearAuthState,
++ persistToken,
++ },
};
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
ddiff --git a/packages/openvibe-shared/test/navbar-auth.test.js b/packages/openvibe-shared/test/navbar-auth.test.js
nnew file mode 100644
iindex 00000000..c6199fa5
---- /dev/null
++++ b/packages/openvibe-shared/test/navbar-auth.test.js
@@@ -0,0 +1,301 @@
++// ═══════════════════════════════════════════════════════════════
++// navbar.js — SSO auth resolution tests (plain node, no browser)
++// Covers: ov_token cookie parsing, host branching (network vs tools
++// apex/gateway/satellite), sign-in link resolution, and the async
++// user-resolution flow (me → gateway refresh → network grace refresh)
++// using stubbed document/location/localStorage/fetch globals.
++// ═══════════════════════════════════════════════════════════════
++
++'use strict';
++
++const assert = require('assert');
++const path = require('path');
++
++const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');
++
++function stubElement() {
++ return {
++ style: {},
++ textContent: '',
++ className: '',
++ id: '',
++ innerHTML: '',
++ dataset: {},
++ classList: { add() {}, remove() {}, toggle() {} },
++ appendChild() {},
++ prepend() {},
++ remove() {},
++ addEventListener() {},
++ querySelector() { return stubElement(); },
++ querySelectorAll() { return []; },
++ };
++}
++
++/**
++ * Build a fresh browser-ish environment and load navbar.js into it.
++ * @param {Object} opts
++ * @param {string} opts.hostname - location.hostname
++ * @param {string} [opts.href] - location.href
++ * @param {string} [opts.cookie] - document.cookie contents
++ * @param {Object} [opts.storage] - initial localStorage entries
++ * @param {Function} [opts.fetch] - fetch stub
++ */
++function loadNavbar(opts) {
++ const calls = [];
++ global.location = {
++ hostname: opts.hostname,
++ href: opts.href || `https://${opts.hostname}/`,
++ protocol: 'https:',
++ };
++ global.window = { location: global.location };
++ global.document = {
++ cookie: opts.cookie || '',
++ head: stubElement(),
++ body: stubElement(),
++ getElementById: () => null,
++ createElement: () => stubElement(),
++ addEventListener() {},
++ removeEventListener() {},
++ dispatchEvent() {},
++ };
++ global.CustomEvent = class CustomEvent {
++ constructor(type, init) { this.type = type; this.detail = init && init.detail; }
++ };
++ const store = { ...(opts.storage || {}) };
++ global.localStorage = {
++ getItem: (k) => (k in store ? store[k] : null),
++ setItem: (k, v) => { store[k] = String(v); },
++ removeItem: (k) => { delete store[k]; },
++ _store: store,
++ };
++ global.fetch = async (url, init) => {
++ calls.push({ url, init: init || {} });
++ if (opts.fetch) return opts.fetch(url, init || {});
++ return { ok: false, status: 404, json: async () => ({}) };
++ };
++
++ delete require.cache[require.resolve(NAVBAR_PATH)];
++ const navbar = require(NAVBAR_PATH);
++ return { navbar, calls, storage: global.localStorage };
++}
++
++function jsonResponse(status, body) {
++ return { ok: status >= 200 && status < 300, status, json: async () => body };
++}
++
++const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null, role: 'user' };
++
++(async () => {
++ // ── 1. Cookie parsing ────────────────────────────────────
++ {
++ const { navbar } = loadNavbar({ hostname: 'openvibe.network' });
++ const p = navbar._auth.parseCookieToken;
++ assert.strictEqual(p('ov_token=abc123'), 'abc123');
++ assert.strictEqual(p('theme=dark; ov_token=abc123; other=1'), 'abc123');
++ assert.strictEqual(p('prefix_ov_token=nope; ov_token=real'), 'real');
++ assert.strictEqual(p('ov_token=a%2Fb'), 'a/b');
++ assert.strictEqual(p('ov_token='), null);
++ assert.strictEqual(p('other=1'), null);
++ assert.strictEqual(p(''), null);
++ }
++
++ // ── 2. Host branching ────────────────────────────────────
++ {
++ const { navbar } = loadNavbar({ hostname: 'openvibe.network' });
++ const a = navbar._auth;
++ assert.strictEqual(a.onToolsDomain('openvibe.tools'), true);
++ assert.strictEqual(a.onToolsDomain('net.openvibe.tools'), true);
++ assert.strictEqual(a.onToolsDomain('maps.openvibe.tools'), true);
++ assert.strictEqual(a.onToolsDomain('mergepdf.openvibe.tools'), true);
++ assert.strictEqual(a.onToolsDomain('openvibe.network'), false);
++ assert.strictEqual(a.onToolsDomain('openvibe.live'), false);
++ assert.strictEqual(a.onToolsDomain('evilopenvibe.tools'), false);
++ assert.strictEqual(a.onNetworkDomain('openvibe.network'), true);
++ assert.strictEqual(a.onNetworkDomain('net.openvibe.tools'), false);
++ }
++
++ // ── 3. Sign-in link resolution ───────────────────────────
++ {
++ const { navbar } = loadNavbar({ hostname: 'openvibe.network' });
++ const a = navbar._auth;
++ const ret = 'https://net.openvibe.tools/?q=8.8.8.8';
++ const enc = encodeURIComponent(ret);
++ // Any tools host — gateway subdomain, satellite, or apex — signs in
++ // through the gateway apex (sets the Domain=.openvibe.tools cookie).
++ assert.strictEqual(a.resolveLoginHref('net.openvibe.tools', ret), `https://openvibe.tools/auth/login?next=${enc}`);
++ assert.strictEqual(a.resolveLoginHref('maps.openvibe.tools', ret), `https://openvibe.tools/auth/login?next=${enc}`);
++ assert.strictEqual(a.resolveLoginHref('openvibe.tools', ret), `https://openvibe.tools/auth/login?next=${enc}`);
++ // Network host uses its own login page.
++ assert.strictEqual(a.resolveLoginHref('openvibe.network', ret), `/login?return=${enc}`);
++ // Anything else (live, games, localhost) keeps the legacy behavior.
++ assert.strictEqual(a.resolveLoginHref('openvibe.live', ret), `https://openvibe.network/login?return=${enc}`);
++ assert.strictEqual(a.resolveLoginHref('localhost', ret), `https://openvibe.network/login?return=${enc}`);
++ }
++
++ // ── 4. loginUrl override hook ────────────────────────────
++ {
++ const { navbar } = loadNavbar({ hostname: 'maps.openvibe.tools' });
++ navbar.init({ service: 'maps', loginUrl: '/custom-login' });
++ assert.strictEqual(navbar._auth.resolveLoginHref('maps.openvibe.tools', 'https://x/'), '/custom-login');
++ }
++
++ // ── 5. Valid cookie token → user chip (the reported bug) ─
++ {
++ const { navbar, calls } = loadNavbar({
++ hostname: 'net.openvibe.tools',
++ href: 'https://net.openvibe.tools/?q=8.8.8.8',
++ cookie: 'ov_token=valid-jwt',
++ fetch: async (url, init) => {
++ if (url === 'https://openvibe.network/api/auth/me') {
++ assert.strictEqual(init.headers.Authorization, 'Bearer valid-jwt');
++ return jsonResponse(200, { user: USER, preferences: { theme_id: 'vibe' } });
++ }
++ return jsonResponse(404, {});
++ },
++ });
++ // Mirrors net.html: token passed, user NOT passed.
++ navbar.init({ service: 'net', apiBase: 'https://openvibe.network', token: 'valid-jwt' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ assert.ok(navbar.getElement().innerHTML.includes('openvibe-navbar-avatar'), 'renders the avatar chip');
++ assert.ok(!navbar.getElement().innerHTML.includes('openvibe-navbar-login'), 'no Sign In button');
++ assert.strictEqual(calls.length, 1, 'exactly one auth call');
++ }
++
++ // ── 6. No token, no sessionUrl → signed out, zero fetches ─
++ {
++ const { navbar, calls } = loadNavbar({ hostname: 'maps.openvibe.tools' });
++ navbar.init({ service: 'maps', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user, null);
++ assert.strictEqual(calls.length, 0, 'anonymous visits make no auth traffic');
++ const html = navbar.getElement().innerHTML;
++ assert.ok(html.includes('https://openvibe.tools/auth/login?next='), 'Sign In goes through the gateway apex');
++ }
++
++ // ── 7. Expired token on a gateway host → same-origin refresh ─
++ {
++ const { navbar, storage } = loadNavbar({
++ hostname: 'openvibe.tools',
++ cookie: 'ov_token=expired-jwt',
++ fetch: async (url, init) => {
++ if (url === 'https://openvibe.network/api/auth/me') {
++ return init.headers.Authorization === 'Bearer fresh-jwt'
++ ? jsonResponse(200, { user: USER })
++ : jsonResponse(401, { error: 'Invalid or expired token' });
++ }
++ if (url === '/auth/refresh') return jsonResponse(200, { token: 'fresh-jwt', user: USER });
++ return jsonResponse(404, {});
++ },
++ });
++ navbar.init({ service: 'tools', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ assert.strictEqual(storage.getItem('ov_token'), 'fresh-jwt', 'refreshed token persisted');
++ assert.ok(global.document.cookie.includes('ov_token=fresh-jwt'), 'cookie updated');
++ assert.ok(global.document.cookie.includes('domain=.openvibe.tools'), 'cookie scoped to .openvibe.tools');
++ }
++
++ // ── 8. Expired token on a satellite → Network grace refresh ─
++ // Satellites do not mount /auth/refresh (404) — the Network's
++ // 7-day-grace POST /api/auth/refresh recovers the session.
++ {
++ const { navbar, storage } = loadNavbar({
++ hostname: 'maps.openvibe.tools',
++ cookie: 'ov_token=expired-jwt',
++ fetch: async (url, init) => {
++ if (url === 'https://openvibe.network/api/auth/me') {
++ return init.headers.Authorization === 'Bearer graced-jwt'
++ ? jsonResponse(200, { user: USER })
++ : jsonResponse(401, {});
++ }
++ if (url === '/auth/refresh') return jsonResponse(404, {}); // satellite: no gateway routes
++ if (url === 'https://openvibe.network/api/auth/refresh') {
++ assert.strictEqual(init.headers.Authorization, 'Bearer expired-jwt');
++ return jsonResponse(200, { token: 'graced-jwt', user: USER });
++ }
++ return jsonResponse(404, {});
++ },
++ });
++ navbar.init({ service: 'maps', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ assert.strictEqual(storage.getItem('ov_token'), 'graced-jwt');
++ assert.ok(global.document.cookie.includes('domain=.openvibe.tools'));
++ }
++
++ // ── 9. Token dead beyond recovery → signed out ───────────
++ {
++ const { navbar } = loadNavbar({
++ hostname: 'net.openvibe.tools',
++ cookie: 'ov_token=dead-jwt',
++ fetch: async (url) => {
++ if (url === 'https://openvibe.network/api/auth/me') return jsonResponse(401, {});
++ return jsonResponse(401, {});
++ },
++ });
++ navbar.init({ service: 'net', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user, null);
++ assert.ok(navbar.getElement().innerHTML.includes('openvibe-navbar-login'), 'renders signed-out');
++ }
++
++ // ── 10. localStorage fallback when no cookie ─────────────
++ {
++ const { navbar } = loadNavbar({
++ hostname: 'openvibe.network',
++ storage: { ov_token: 'ls-jwt' },
++ fetch: async (url, init) => {
++ if (url === 'https://openvibe.network/api/auth/me') {
++ assert.strictEqual(init.headers.Authorization, 'Bearer ls-jwt');
++ return jsonResponse(200, { user: USER });
++ }
++ return jsonResponse(404, {});
++ },
++ });
++ navbar.init({ service: 'network', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ }
++
++ // ── 11. Cookie wins over stale localStorage ──────────────
++ {
++ const { navbar } = loadNavbar({
++ hostname: 'net.openvibe.tools',
++ cookie: 'ov_token=cookie-jwt',
++ storage: { ov_token: 'stale-ls-jwt' },
++ });
++ assert.strictEqual(navbar._auth.resolveToken(), 'cookie-jwt');
++ }
++
++ // ── 12. sessionUrl hook (app-specific session, no token) ─
++ {
++ const { navbar } = loadNavbar({
++ hostname: 'openvibe.live',
++ fetch: async (url) => {
++ if (url === '/api/session') return jsonResponse(200, { user: USER });
++ return jsonResponse(404, {});
++ },
++ });
++ navbar.init({ service: 'live', apiBase: 'https://openvibe.network', sessionUrl: '/api/session' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ }
++
++ // ── 13. Explicit user passed → no auth traffic at all ────
++ {
++ const { navbar, calls } = loadNavbar({
++ hostname: 'openvibe.network',
++ cookie: 'ov_token=whatever',
++ });
++ navbar.init({ service: 'network', token: 'whatever', user: USER });
++ await new Promise((r) => setImmediate(r));
++ assert.strictEqual(calls.length, 0, 'pages that pass a user keep full control');
++ assert.ok(navbar.getElement().innerHTML.includes('openvibe-navbar-avatar'));
++ }
++
++ console.log('navbar-auth: all tests passed');
++})().catch((err) => {
++ console.error(err);
++ process.exit(1);
++});