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'; // **2**, for the `playlists` store (docs/playlists.md §14.2). The version and // every store this database has live here, in one place, and `openDB` is // exported so nothing else opens it at a version of its own — two modules // disagreeing about the version is a `VersionError` thrown at whichever of // them happens to run second. const IDB_VERSION = 2; const OPEN_DB_TIMEOUT_MS = 5000; const IDB_STORE = 'group_indexes'; const IDB_PLAYLISTS = 'playlists'; function navigate(path) { window.location.hash = path; } // ── IndexedDB cache ───────────────────────────────────────────────────────── /** * The database, opened — or refused, but never left hanging. * * A version upgrade waits for every other connection to this database to * close. A second tab of this site holding version 1 open is enough to stop * it, and `indexedDB.open` then fires **neither** `success` nor `error`: it * fires `blocked`, and if nothing handles that the promise never settles. Every * `await openDB()` behind it waits for ever, which reads as a feature that * silently does nothing rather than as a failure anybody can see. * * So `blocked` is heard, and a deadline covers the rest. Callers already treat * a rejection as "no local cache this time" and carry on. */ function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); let settled = false; const done = (fn, arg) => { if (!settled) { settled = true; fn(arg); } }; // Generous: the other tab is asked to close and usually does within a // frame. This is the backstop for the one that cannot — a page suspended // on a phone, say — not a latency budget. const deadline = setTimeout(() => done(reject, new Error( 'IndexedDB open timed out (another tab may hold an older version open)')), OPEN_DB_TIMEOUT_MS); req.onblocked = () => done(reject, new Error( 'IndexedDB upgrade blocked by another tab of this site')); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); } // Added at version 2. Out-of-line keys: the records are the playlist // objects themselves, and the key is `userId|kind` — two accounts on one // browser is ordinary, and so is a sign-out that never runs, so the // separation belongs in the key rather than in a cleanup path. if (!db.objectStoreNames.contains(IDB_PLAYLISTS)) { db.createObjectStore(IDB_PLAYLISTS); } }; req.onsuccess = () => { clearTimeout(deadline); // Giving up does not cancel the request: the other tab eventually closes, // the upgrade goes through, and this fires with a live connection nobody // is waiting for. Left open it squats the database — blocking the next // upgrade *and* any attempt to delete it, which is the failure this // whole guard exists to end, arriving through the back door. if (settled) { try { req.result.close(); } catch { /* already gone */ } return; } done(resolve, req.result); }; req.onerror = () => { clearTimeout(deadline); done(reject, req.error); }; }); } // `group_indexes` held a decrypted copy of every group's index — each file's // name, path, size, hash and uploader — written on every index and every delta, // and read by the cross-group search of the time, which searched those records // instead of dialling anything. // // Search has dialled the nodes since 2026-08-28. The reader went with that // change and the writers stayed, so for weeks the browser kept building a // cleartext file listing that nothing consulted and no sign-out removed: the key // database is a different one. It is **L7** — code nothing calls does not sit // still, it accumulates. // // Showing a group's files while its node is unreachable was the only use left // for such a cache, and it is not wanted: a listing you cannot open is worse // than an honest absence. // // The store itself is left in the schema. Dropping it means a version bump, and // a version bump means an upgrade another tab can block — which would take // playlists down with it, since they share this database. Emptying it costs // nothing and leaves nothing behind. async function purgeGroupIndexCache() { 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. // // `recoveryKey` (docs/MESHBAY_DESIGN.md §3.6) is the AES key that wraps the // *recovery* copy of an identity bundle. In-memory only, and set only when the // user has just generated or entered the recovery secret (registration, or the // Flow B screen) — it cannot be re-derived from the passphrase. const session = { bundleKey: null, pendingJoinCode: null, recoveryKey: 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 _storeKey(slot, key) { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readwrite'); tx.objectStore('k').put(key, slot); await new Promise(r => { tx.oncomplete = r; }); db.close(); } catch {} } async function _loadKey(slot) { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readonly'); const g = tx.objectStore('k').get(slot); const val = await new Promise(r => { g.onsuccess = () => r(g.result); }); db.close(); return val || null; } catch { return null; } } // 'bk' = passphrase-derived bundle key; 'rk' = recovery key // (docs/MESHBAY_DESIGN.md §3.6). Persisting 'rk' is what lets a group joined // in a *later* session still get a recovery-wrapped identity copy, instead of // only groups joined in the unbroken session that generated it. Cleared with // everything else on sign-out. const _storeBundleKey = (key) => _storeKey('bk', key); const _loadBundleKey = () => _loadKey('bk'); const _storeRecoveryKey = (key) => _storeKey('rk', key); const _loadRecoveryKey = () => _loadKey('rk'); 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() { let auth; try { auth = JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; } // A session is an identity and a token. An object carrying only the token is // what a sign-out racing a renewal used to write (see `refreshAccessToken`), // and it is worse than no session: the app renders the signed-in interface // from it and throws on the first field it reads. That fix stops new ones // being written; this one lets a browser already holding one heal itself on // the next load, instead of needing somebody to find the reset button. if (auth && (!auth.username || !auth.userId)) { try { localStorage.removeItem(AUTH_KEY); } catch { /* private mode */ } return null; } return auth; } function saveAuth(auth) { if (auth) { localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); } else { localStorage.removeItem(AUTH_KEY); session.bundleKey = null; session.recoveryKey = 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(); // Signing out while this was in flight is not rare — it is the ordinary // shape of a tab left open: the idle watch signs out at the same moment // the renewal fires, one second apart in the hub's log. `_auth` is null // by now, and `{ ..._auth }` spreads null to `{}` without complaining, so // what got written back was a token and *no identity at all*: an object // the app believes is a session, renders the signed-in interface from, // and throws on at the first field it reads — `username[0]`, a blank page // on every load afterwards, in localStorage, surviving everything but a // reset of the site's data. // // A sign-out that arrives during a renewal wins. There is nothing here // worth saving over it. if (!_auth) return null; 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; } /** * Revoke this session's refresh token on the hub. * * Fire and forget, and read synchronously: the caller clears the session on the * next line, and signing out must not wait on the network or fail with it. */ function logoutOnHub() { const refreshToken = _auth && _auth.refreshToken; if (!refreshToken) return; platform.apiFetch(HUB + '/v1/users/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }).catch(() => {}); } /** 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(); } // `openDB` and `IDB_PLAYLISTS` are exported so playlists.js reads and writes // its own store without owning the database's version. export { HUB, navigate, session, openDB, IDB_PLAYLISTS, purgeGroupIndexCache, _storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, tokenLifeLeft, refreshAccessToken, ensureFreshToken, logoutOnHub, hubFetch, };