aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 19:27:38 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 19:27:38 +0200
commit005f3cf83559eaf84fd307584477c40676be1dd3 (patch)
treead1668e1e99f3e0cb03f559cfdec6d27cf742d5b /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parent36ef1b76dce56a40650f890156eb137536ef015d (diff)
downloadmeshbay-005f3cf83559eaf84fd307584477c40676be1dd3.tar.gz
fix(client): degrade against a node still speaking MNP 1.0
The SPA is served by the hub, so deploying the hub puts this client in front of every node — including the ones not updated yet. That window is the normal state for as long as an operator takes, and for a node someone else runs it may be indefinite. Three controls were broken across it, and the failure mode is quiet: an unknown message type is logged by the node and never answered, so the click produces a thirty-second wait ending in a timeout with nothing on screen to say the node simply cannot do this. Files' Upload button read `root.writable`, which a 1.0 node does not send — it says `upload`, the same answer under the older name. The button disappeared on every un-upgraded node. It reads both now, and still respects an explicit `writable: false` rather than falling through to the legacy flag. The per-app folder pickers spoke `app_directories`. Videos, Music and Photos each had their own message before that and those still work, so the page chooses by version: an operator on an older node keeps the ability they had. `video_root` and `audio_root` hold one folder, so several are refused with a reason rather than stored as the first and silently truncated. Root management — writable, removable, eject, plug — has no older equivalent to route to, so the table goes read-only with a line saying why and pointing at the `meshbay-node root` commands. Chat's two settings are new with nothing before them and are hidden the same way. None of this was inferred from a payload's shape: `_checkNodeVersion` already parsed the node's version and threw it away, and it is kept now. Coupling a capability to whether some field happens to be present is how a flag flips because an unrelated payload changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js53
1 files changed, 53 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 785b926..32f8539 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -335,6 +335,25 @@ class MeshBayTransport {
set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
set onRootsChanged(fn) { this._onRootsChanged = fn; }
+
+ /** The MNP version the connected node declared, or '' before a handshake. */
+ get nodeVersion() { return this._nodeVersion || ''; }
+
+ /**
+ * Whether the node speaks the per-root and per-app operations MNP 1.1 added:
+ * `root_update`/`root_eject`/`root_plug`, `app_directories`,
+ * `chat_directory`, `chat_link_preview`.
+ *
+ * An older node has no equivalent for the root ones at all, and answers the
+ * app ones through their three predecessors (`video_root`, `audio_root`,
+ * `photo_roots`). The caller chooses which; what it must not do is send a
+ * 1.1 message and wait, because an unknown type is logged and dropped.
+ */
+ get supportsAppOps() {
+ const m = /^(\d+)\.(\d+)$/.exec(this._nodeVersion || '');
+ if (!m) return false;
+ return (Number(m[1]) > 1) || (Number(m[1]) === 1 && Number(m[2]) >= 1);
+ }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
set onAppDirectories(fn) { this._onAppDirectories = fn; }
set onChatDirectory(fn) { this._onChatDirectory = fn; }
@@ -600,6 +619,12 @@ class MeshBayTransport {
// block, because everything below — the join, the proof, the sealed ack
// — assumes both sides mean the same thing by each message.
_checkNodeVersion(reply);
+ // Kept, not just checked. Several controls exist only on a node new
+ // enough to have them, and the alternative to asking is offering a
+ // button whose message an older node logs as unknown and never answers
+ // — a 30-second wait ending in a timeout, with nothing on screen to say
+ // the node simply cannot do this.
+ this._nodeVersion = String(reply.v || '');
if (!window.MeshBayCrypto) {
throw new Error('Node requires GEK proof but no crypto available');
}
@@ -1195,6 +1220,34 @@ class MeshBayTransport {
* Cleaned and sorted the same way the node does, so both sides build the
* same bytes to sign.
*/
+ /**
+ * The same instruction a node too old for `app_directories` understands.
+ *
+ * Videos, Music and Photos each had their own message before this, and they
+ * still work — so an operator on an un-upgraded node keeps the ability they
+ * had, rather than being handed a control that silently times out. Chat has
+ * no predecessor, which is why its settings are hidden rather than routed.
+ */
+ async setAppDirectoriesLegacy(appKey, directories, signFn) {
+ const clean = [...new Set(
+ (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
+ )].sort();
+ if (appKey === 'photo') return this.setPhotoRoots(clean, signFn);
+ // One folder was all these two could carry. Sending several would store
+ // the first and silently drop the rest, so it is refused instead.
+ if (clean.length > 1) {
+ throw new Error(
+ 'This node is older than this page and can hold one folder per app. '
+ + 'Update it, or choose a single folder.');
+ }
+ const one = clean[0] || '';
+ if (appKey === 'video') return this.setVideoRoot(one, signFn);
+ if (appKey === 'music') return this.setAudioRoot(one, signFn);
+ throw new Error(
+ 'This node is older than this page and cannot store this app\'s '
+ + 'folders. Its operator has to update it.');
+ }
+
async setAppDirectories(appKey, directories, signFn) {
const clean = [...new Set(
(directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),