import * as platform from './platform.js'; // Where the hub is. Empty in a browser — it served this page, so a relative // path cannot be pointed at the wrong place. In the installed app the page // comes from disk and has no origin of its own, so the base is configured. // See platform.js. const HUB = platform.hubBase(); const AUTH_KEY = 'mb_auth'; // Renew an access token with this much life left rather than waiting for it to // fail. Generous against a one-hour token: a film is watched without the hub // hearing a word, and coming back to a tab that has been asleep for an hour // should not cost a round trip before the first click works. const TOKEN_RENEW_MARGIN_S = 600; const IDB_NAME = 'meshbay'; const IDB_VERSION = 1; const IDB_STORE = 'group_indexes'; function navigate(path) { window.location.hash = path; } // ── IndexedDB cache ───────────────────────────────────────────────────────── function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function cacheGroupIndex(groupId, groupName, groupOwner, entries, roots) { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).put({ groupId, groupName, groupOwner, entries, roots: roots || {}, cachedAt: Date.now(), }); await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); db.close(); } catch { /* best-effort */ } } async function getCachedGroupIndex(groupId) { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).get(groupId); const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); db.close(); return result || null; } catch { return null; } } async function getAllCachedIndexes() { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).getAll(); const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); db.close(); return result || []; } catch { return []; } } async function clearAllCachedIndexes() { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).clear(); await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); db.close(); } catch { /* best-effort */ } } // ── Auth persistence ───────────────────────────────────────────────────────── // The key that opens a node's keypair bundle, derived once at sign-in, and a // one-time pairing/join code the user just typed — consumed by the next // connection attempt. Neither is persisted: the bundle key is re-derived (or // re-fetched from a node's own backup, see `_loadBundleKey`) each session, and // a code is single-use and short-lived. A plain object, not two bare `let`s, // so importing modules can update either field without this module handing // out a rebindable export. const session = { bundleKey: null, pendingJoinCode: null }; function _openKeyDB() { return new Promise((resolve, reject) => { const req = indexedDB.open('meshbay_keys', 1); req.onupgradeneeded = () => req.result.createObjectStore('k'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function _storeBundleKey(key) { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readwrite'); tx.objectStore('k').put(key, 'bk'); await new Promise(r => { tx.oncomplete = r; }); db.close(); } catch {} } async function _loadBundleKey() { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readonly'); const g = tx.objectStore('k').get('bk'); const val = await new Promise(r => { g.onsuccess = () => r(g.result); }); db.close(); return val || null; } catch { return null; } } async function _clearKeyDB() { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readwrite'); tx.objectStore('k').clear(); await new Promise(r => { tx.oncomplete = r; }); db.close(); } catch {} } function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; } } function saveAuth(auth) { if (auth) { localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); } else { localStorage.removeItem(AUTH_KEY); session.bundleKey = null; _clearKeyDB(); } } // ── Session ────────────────────────────────────────────────────────────────── // // The access token lasts an hour and the refresh token thirty days. Nothing was // using the second: `hubFetch` reported a 401 as an error like any other, so an // hour of watching a film — during which the hub hears nothing, because the // video comes over WebRTC — ended with "token expired or invalid" and no way // out but signing out and back in. Reopening the tab the next day did the same, // with a perfectly good refresh token sitting in localStorage beside the stale // access one. // // This lives outside any component because `hubFetch` is a plain function and // has to be able to renew a token mid-request without every caller passing the // machinery down to it. let _auth = loadAuth(); let _onAuthChange = null; // set by App, so the UI follows a background renewal let _refreshing = null; // in flight, shared: see refreshAccessToken /** App() calls this once, to hear about a renewal that happened in the background. */ function setAuthChangeListener(fn) { _onAuthChange = fn; } function setAuth(auth) { _auth = auth; saveAuth(auth); if (_onAuthChange) _onAuthChange(auth); } /** Seconds until this JWT expires, or null if it says nothing useful. */ function tokenLifeLeft(token) { try { const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); if (!payload.exp) return null; return payload.exp - Math.floor(Date.now() / 1000); } catch { return null; // not a JWT we can read; treat as unknown, never as expired } } /** * Trade the refresh token for a new pair. * * The hub rotates: it revokes the token presented and returns a new one, and a * revoked token presented again revokes the whole family. So the new one must * be stored — the previous code kept only the access token and dropped its * replacement, which burned the refresh token on first use and locked the * account out of renewal on the second. That is why signing out and in was the * only way back. * * Concurrent callers share one request. Two 401s racing would otherwise send * the same refresh token twice, and the second would look exactly like theft. */ async function refreshAccessToken() { if (!_auth || !_auth.refreshToken) return null; if (_refreshing) return _refreshing; _refreshing = (async () => { try { const r = await platform.apiFetch(HUB + '/v1/users/token/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: _auth.refreshToken }), }); if (!r.ok) { // Expired, revoked, or the family was torn down. Nothing to salvage: // sign out cleanly rather than leave a session that fails every call. setAuth(null); return null; } const data = await r.json(); setAuth({ ..._auth, token: data.access_token, refreshToken: data.refresh_token || _auth.refreshToken, }); return data.access_token; } catch { return null; // offline: keep the session, the next call can try again } finally { _refreshing = null; } })(); return _refreshing; } /** Renew before it bites, rather than after. */ async function ensureFreshToken() { if (!_auth || !_auth.token) return null; const left = tokenLifeLeft(_auth.token); if (left !== null && left > TOKEN_RENEW_MARGIN_S) return _auth.token; return refreshAccessToken(); } // ── Hub API ────────────────────────────────────────────────────────────────── async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { const headers = {}; if (body) headers['Content-Type'] = 'application/json'; // Prefer the token the session currently holds. Callers read theirs from // React state, which is a render behind a renewal that happened in the // background — and sending the stale one would 401 for no reason. const bearer = token && _auth && _auth.token ? _auth.token : token; if (bearer) headers['Authorization'] = `Bearer ${bearer}`; const opts = { method, headers }; if (body) opts.body = JSON.stringify(body); const r = await platform.apiFetch(HUB + path, opts); if (r.status === 401 && bearer && !_retried) { // The one case worth a second attempt: the access token aged out while // nothing was talking to the hub. Renew once and replay. If the renewal // fails it signs out, and the replay below is skipped. const fresh = await refreshAccessToken(); if (fresh) { return hubFetch(path, { method, body, token: fresh, _retried: true }); } } if (!r.ok) { const err = await r.json().catch(() => ({ detail: r.statusText })); const detail = Array.isArray(err.detail) ? err.detail.map(e => e.msg || JSON.stringify(e)).join(', ') : (err.detail || r.statusText); throw new Error(String(detail)); } return r.json(); } export { HUB, navigate, session, cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes, _storeBundleKey, _loadBundleKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch, };