aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-10 17:29:50 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-10 17:29:50 +0200
commit4753c67816c774323e3ab4efc76d3259e8ded40d (patch)
tree0ac0b8a3013aa483111ea249e3aa37ff49988891 /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parent1dcedc77083b908b7b3b431bad679a4813884355 (diff)
downloadmeshbay-4753c67816c774323e3ab4efc76d3259e8ded40d.tar.gz
refactor(spa): stop asking a node what version it is
`MNP_MIN_SUPPORTED` is the version this build speaks, so `check_version` refuses everything below it at the handshake. Every capability the client was gating on the node's version is therefore true of every peer it can reach: * `supportsSealedUpload` — an upload is sealed or it is not sent; * `supportsAppOps` — one `app_directories` op, and no `setVideoRoot` / `setAudioRoot` / `setPhotoRoots` wrappers behind it; * `supportsTransferSlots` and `Lease._skip()` — a lease is always real, so there is no branch where a transfer runs without one; * `legacyNode`, the read-only shared-directories table, and the two hints telling an operator their node is too old to configure an app. The version the node declares is still recorded, for diagnostics. Nothing branches on it, and the comment says so, because a field kept "just in case" is how the branches came back last time. `test_mnp_1_0_node_compat.py` goes with them: it existed to hold the fallbacks in place, and holding a fallback that cannot execute is how a suite starts lying. The two locale strings for those hints are removed from all ten catalogues. Hub suite 872 passed (test_sticky_header deselected — failing before this). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js183
1 files changed, 11 insertions, 172 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 99e3fb9..2e3712c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -115,8 +115,7 @@ function _replayBroadcast(transport, msg) {
}
const ADMIN_OP_TYPES = new Set([
- 'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root',
- 'photo_roots',
+ 'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled',
'musicbrainz_enabled', 'file_delete', 'dir_delete',
'apps_enabled', 'set_scan_settings', 'member_revoke',
'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug',
@@ -339,12 +338,6 @@ class Lease {
this._wait = new Promise((resolve) => { this._granted = resolve; });
}
- /** No slots on this node: behave as though one was granted at once. */
- _skip() {
- this.state = 'granted';
- this._granted();
- }
-
_request() {
// A closed channel is not a failure here, and must not throw: the transport
// reconnects on its own, `_reopenTransfers` re-asks for every live lease
@@ -417,7 +410,6 @@ class Lease {
this.closed = true;
clearTimeout(this._watchdog);
this.transport._leases.delete(this.tr);
- if (!this.transport.supportsTransferSlots) return;
try {
this.transport._send({ type: 'transfer_close', v: '0.1', tr: this.tr,
reason });
@@ -530,48 +522,9 @@ class MeshBayTransport {
/** The MNP version the connected node declared, or '' before a handshake. */
get nodeVersion() { return this._nodeVersion || ''; }
- /**
- * Whether this node hands out transfer slots.
- *
- * Read from the handshake ack rather than from the MNP version: the caps
- * shipped before the version bump that will make leases compulsory, so for
- * now a node either answers with `transfer_limits` or it predates all of
- * this. A node that does not is asked for nothing and enforces nothing —
- * every download behaves exactly as it did.
- */
- get supportsTransferSlots() { return this._transferLimits !== null; }
-
/** This member's own caps in this group, or null when the node said nothing. */
get transferLimits() { return this._transferLimits; }
- /**
- * 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);
- }
- /**
- * Whether the node opens a sealed upload (MNP 2.0).
- *
- * A 1.x node reads `filename` and `data` off the message itself, finds
- * neither — they are inside the seal — and answers "Missing filename or
- * data", an error about the wrong thing that names no upload_id and so fails
- * every upload in flight. Asked before sending rather than discovered after,
- * for the same reason `supportsAppOps` is.
- */
- get supportsSealedUpload() {
- const m = /^(\d+)\.(\d+)$/.exec(this._nodeVersion || '');
- return !!m && Number(m[1]) >= 2;
- }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
set onAppDirectories(fn) { this._onAppDirectories = fn; }
set onChatDirectory(fn) { this._onChatDirectory = fn; }
@@ -867,11 +820,9 @@ 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.
+ // Kept for diagnostics only. Nothing branches on it: the range check
+ // above is what decides whether these two can talk at all, and a peer it
+ // admits speaks every message in this file.
this._nodeVersion = String(reply.v || '');
if (!window.MeshBayCrypto) {
throw new Error('Node requires GEK proof but no crypto available');
@@ -1075,15 +1026,6 @@ class MeshBayTransport {
Object.assign(ack, config);
this._transferLimits = ack.transfer_limits || null;
- // Tell the node which of this account's devices is on this connection.
- // Deliberately after the ack, and gated on the node's own version rather
- // than sent hopefully: a node that does not know the message answers
- // nothing at all, which would leave a `device_hello` sitting in
- // `_pending` for the full 30s — and the arrival-order fallback hands an
- // unrouted reply to the *oldest* pending request, which right after a
- // handshake is exactly this one. That is the routed-by-luck bug the chat
- // ack comment above was written for; not repeating it.
- this._nodeMnp = String(ack.v || '');
// From the *sealed* part of the ack: a forged epoch would have this
// client sealing under a key the group has retired.
this.chatEpoch = ack.chat_epoch || 0;
@@ -1093,9 +1035,10 @@ class MeshBayTransport {
this._chatKeysInFlight = null;
this._roster = null;
this._rosterInFlight = null;
- // Not gated on a version any more: a node that reached this point speaks
- // MNP 2.0, where identifying the device is what makes chat possible at
- // all. `check_version` refused anything older before we got here.
+ // Tell the node which of this account's devices is on this connection,
+ // after the ack and unconditionally: a peer `check_version` admitted
+ // speaks this message, and identifying the device is what makes chat
+ // possible at all.
await this._announceDevice().catch((e) => {
console.warn('[MeshBay] device_hello failed — chat will not work:', e);
});
@@ -1349,10 +1292,6 @@ class MeshBayTransport {
openTransfer({ kind = 'download', bytes = 0, chunks = 0, onState = null } = {}) {
const tr = _hex(crypto.getRandomValues(new Uint8Array(16)));
const lease = new Lease(this, tr, kind, bytes, chunks, onState);
- if (!this.supportsTransferSlots) {
- lease._skip();
- return lease;
- }
this._leases.set(tr, lease);
lease._request();
return lease;
@@ -1360,7 +1299,6 @@ class MeshBayTransport {
/** Re-ask for every live lease. Called after a reconnect. */
_reopenTransfers() {
- if (!this.supportsTransferSlots) return;
for (const lease of this._leases.values()) {
// The node lost the lease with the session, so this is a fresh request
// for the same `tr` — which the node treats as the same transfer rather
@@ -1450,7 +1388,7 @@ class MeshBayTransport {
}
/**
- * Correct a wrong automatic TMDB match. Signed like setVideoRoot/
+ * Correct a wrong automatic TMDB match. Signed like
* setTmdbConfig: it replaces what every member sees for a show/movie,
* node-wide (media_cache is shared, not per-viewer) — an unsigned
* override would let any member vandalize another show's metadata.
@@ -1520,7 +1458,7 @@ class MeshBayTransport {
* Whether TMDB lookups run for this group at all — per-group (2026-08-24,
* used to be node-wide): a real media-library group and a test/demo group
* on the same node need not share the decision to spend TMDB quota and
- * make outbound requests. Signed like setVideoRoot — it decides whether
+ * make outbound requests. Signed like the rest — it decides whether
* this group's members' Videos tab ever makes outbound TMDB traffic.
*/
async setTmdbEnabled(enabled, signFn) {
@@ -1538,100 +1476,6 @@ class MeshBayTransport {
return msg;
}
- /**
- * Which folder (possibly a subfolder of a shared root) the Videos app
- * treats as its entry point for this group. `path: ''` means the whole
- * group index. Signed like setAppsEnabled — it decides what every
- * member's Videos tab shows.
- */
- async setVideoRoot(path, signFn) {
- const clean = (path || '').replace(/^\/+|\/+$/g, '');
- const msg = await this._sendAndWait({ type: 'video_root', v: '0.5', path: clean });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'video_root', clean, signFn);
- }
- return msg;
- }
-
- /**
- * Same shape as setVideoRoot above — the Music app's own entry point.
- */
- async setAudioRoot(path, signFn) {
- const clean = (path || '').replace(/^\/+|\/+$/g, '');
- console.log('[MeshBay] setAudioRoot: sending request, path=', JSON.stringify(clean));
- const msg = await this._sendAndWait({ type: 'audio_root', v: '0.10', path: clean });
- console.log('[MeshBay] setAudioRoot: first reply =', msg);
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'audio_root', clean, signFn);
- }
- return msg;
- }
-
- /**
- * Which folder(s) the Photos app treats as its entry points for this
- * group (docs/photos.md §2.1). Unlike setVideoRoot/setAudioRoot, `roots`
- * is a whole set, replaced in one signed op — same shape as
- * setAppsEnabled. The client normalizes the same way the node does
- * (webrtc_server.py's `_do_photo_roots`: trim slashes, drop empties,
- * dedupe, sort) so the subject built here matches byte-for-byte what the
- * node signs the challenge against.
- */
- async setPhotoRoots(roots, signFn) {
- const clean = [...new Set(
- (roots || []).map((r) => (r || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
- )].sort();
- const msg = await this._sendAndWait({ type: 'photo_roots', v: '0.11', roots: clean });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'photo_roots', clean.join(','), signFn);
- }
- return msg;
- }
-
- /**
- * Point an application at folder(s) inside the group's shared directories.
- *
- * One method for every app, keyed by the app's registry name — the same
- * generic op the node grew for the same reason (docs/refactor-groups.md
- * §1.6). `setVideoRoot`, `setAudioRoot` and `setPhotoRoots` are still here
- * and still work; nothing new should call them.
- *
- * The subject names the app as well as the paths, because an operator shown
- * "Media/Films" alone cannot tell which application is about to be pointed
- * at it, and two apps' challenges would otherwise be indistinguishable.
- * 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),
@@ -2471,11 +2315,6 @@ class MeshBayTransport {
throw new Error(`${file.name} is already being uploaded`);
}
if (!this._gekRaw) throw new Error('This group has no key on this device');
- if (!this.supportsSealedUpload) {
- throw new Error(
- 'This node is running an older MeshBay and cannot accept an upload '
- + 'from this page. Its operator has to update it.');
- }
const C = window.MeshBayCrypto;
const groupId = (this._connectArgs && this._connectArgs.groupId) || '';
this._inFlightUploads.add(file.name);
@@ -3143,7 +2982,7 @@ class MeshBayTransport {
// generic "oldest pending" fallback further down. Returns as soon as a
// match resolves: this transport instance is the one that submitted
// the request, and its own caller already updates local state from
- // what *it* sent (setAppsEnabled/setVideoRoot/... callers all do
+ // what *it* sent (setAppsEnabled/setAppDirectories/... callers all do
// `onX(next)` with their own local value, never by reading the ack),
// so the broadcast-oriented per-type handlers below — there for every
// *other* connected client learning the change — have nothing left to