diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
| commit | 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch) | |
| tree | b13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/hub-client.js | |
| parent | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff) | |
| download | meshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz | |
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/hub-client.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/hub-client.js | 269 |
1 files changed, 269 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js new file mode 100644 index 0000000..aaf7ddb --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -0,0 +1,269 @@ +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, entries) { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).put({ + groupId, groupName, entries, 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 []; } +} + +// ── 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, + _storeBundleKey, _loadBundleKey, _clearKeyDB, + loadAuth, saveAuth, setAuth, setAuthChangeListener, + tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch, +}; |