openvibers/openvibe.network Commits
Navbar: resolve session from ov_token via /api/auth/me; host-aware sign-in/out links; token refresh
Changes
@@@ -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"},@@@ -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, '&').replace(/"/g, '"');}@@@ -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;nnew file mode 100644@@@ -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);++});
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -3,12 +3,17 @@
@@ -3,12 +3,17 @@
// Consistent top bar across all services with logo, navigation,
// Consistent top bar across all services with logo, navigation,
// notification bell, account switcher, and theme-aware styling.
// notification bell, account switcher, and theme-aware styling.
// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
// 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) {
(function (root) {
'use strict';
'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;
let _navEl = null;
function injectStyles() {
function injectStyles() {
@@ -429,6 +434,200 @@
@@ -429,6 +434,200 @@
try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
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) {
function escapeAttr(value) {
return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');
return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"');
}
}
@@ -497,8 +696,8 @@
@@ -497,8 +696,8 @@
const u = _config.user;
const u = _config.user;
const accounts = getAccounts();
const accounts = getAccounts();
const isAnon = u && u.is_anon;
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 = `
nav.innerHTML = `
<a class="openvibe-navbar-brand" href="${brandHref}">
<a class="openvibe-navbar-brand" href="${brandHref}">
@@ -588,12 +787,16 @@
@@ -588,12 +787,16 @@
dropdown.classList.remove('open');
dropdown.classList.remove('open');
if (_config.onLogout) _config.onLogout();
if (_config.onLogout) _config.onLogout();
else {
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 {
} else {
@@ -621,9 +824,19 @@
@@ -621,9 +824,19 @@
init(opts = {}) {
init(opts = {}) {
Object.assign(_config, opts);
Object.assign(_config, opts);
injectStyles();
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). */
/** Update user (after account switch). */
setUser(user) {
setUser(user) {
_config.user = user;
_config.user = user;
@@ -643,6 +856,18 @@
@@ -643,6 +856,18 @@
_navEl?.remove();
_navEl?.remove();
_navEl = null;
_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;
if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
new file mode 100644
new file mode 100644
@@ -0,0 +1,301 @@
@@ -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);
+});