summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-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.js269
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,
+};