From 53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 14:00:22 +0200 Subject: feat: MNP 3.0 — a transfer needs a lease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4 of ~/next/improve-downloads.md, the flag day. Leases become compulsory and a 2.x peer is refused at the handshake. **The bound on leaseless reads (§3.4.1) did not exist, and it is what makes the rest mean anything.** Browsing a group is never subject to a transfer slot — that is an operator decision and a requirement: a member must be able to browse a group at capacity exactly as they browse an idle one. But "not leased" cannot mean "unbounded", or a client that simply omits `tr` transfers outside every cap and the caps are decoration. A session may now read two distinct files at once without a lease: one because a viewer looks at one file, two so that prefetching the next photo stays possible. A count of files and not a byte budget, because a RAW photo is 60-80 MB and is browsing while a 40 MB archive is a download, and no size threshold separates them. Thumbnails, posters and cover art never reach this check at all — they resolve out of the node's own cache. It is a fairness control among cooperating clients, in the company of `max_concurrent_streams`, and is not a defence against a member determined to saturate a node's disk. That member is a member, and the answer to them is `member revoke`. **MNP_VERSION and MNP_MIN_SUPPORTED both move to 3.0**, on both sides. The messages are additive; the requirement is not. An opt-in switch would leave a leaseless branch reachable on every node, which is finding C6's lesson — a transport that accepted a bare JWT — one feature later. **The desktop client now checks before it connects.** The SPA is served by the hub and picks up a new client on reload; the application ships its own interface, so an un-updated one would sign in, list groups, and fail every connection with `version_too_old` — a refusal in a protocol vocabulary with nothing anyone can act on. It asks `/v1/hub/version` for `client.minimum` and says so plainly instead. An unreachable hub is deliberately *not* "too old": a captive portal or a closed laptop must not make starting the application impossible. **Every package is aligned on 0.13.0.** `meshbay-client/package.json` had drifted to 1.0.0 while the Python packages were on 0.12.0 — invisible until something compared those numbers, and then load-bearing: an installed client announcing 1.0.0 sorts above a 0.13.0 minimum and walks through the gate meant to stop it. That is stated in the code rather than left to be rediscovered; it is acceptable exactly once, because the operator is updating every client, node and hub by hand for this flag day. A new test fails if two packages ever disagree again, and another fails if the hub would refuse the client the tree builds. Node suite 1209 passed, hub suite 861 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- packages/meshbay-client/src/main.js | 70 ++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-client/src') diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 9ff0069..ab579a1 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -1701,6 +1701,71 @@ function describeUnreachable(url, error) { return `Could not reach ${url}: ${detail}`; } +// ── The version gate ──────────────────────────────────────────────────────── + +/** Compare two dotted versions. -1, 0 or 1; unreadable sorts as equal. */ +function compareVersions(a, b) { + const parse = (v) => String(v || '').split('.').map((n) => parseInt(n, 10)); + const [x, y] = [parse(a), parse(b)]; + if (x.some(Number.isNaN) || y.some(Number.isNaN)) return 0; + for (let i = 0; i < Math.max(x.length, y.length); i++) { + const d = (x[i] || 0) - (y[i] || 0); + if (d) return d < 0 ? -1 : 1; + } + return 0; +} + +/** + * Refuse to start when this build is older than the hub will talk to. + * + * The reason this exists rather than letting the handshake do it: the SPA is + * served by the hub and picks up a new client on reload, but **this + * application ships its own interface**. On the MNP 3.0 flag day an + * un-updated one can still sign in, still list groups, and then fail every + * connection with `version_too_old` — a refusal in a protocol vocabulary, + * surfacing as a node that will not talk, with nothing anyone can act on. + * + * So the question is asked once, up front, of `/v1/hub/version`, which has + * carried `client.minimum` since before there was a client to check it. + * + * **Unreachable is not too old.** A hub that is down, a laptop with no network, + * a captive portal: none of those are a reason to refuse to open the + * application, and treating them as one would make an offline start impossible + * for ever. Only a definite answer, saying in so many words that this version + * is below the minimum, stops anything. + */ +async function refuseIfTooOld() { + const base = String(config.hubBase || '').replace(/\/+$/, ''); + if (!base) return false; // First run: there is no hub to ask yet. + let info; + try { + const r = await fetch(`${base}/v1/hub/version`, + { signal: AbortSignal.timeout(10000) }); + if (!r.ok) return false; + info = await r.json(); + } catch { + return false; + } + const minimum = info && info.client && info.client.minimum; + if (!minimum) return false; + const mine = app.getVersion(); + if (compareVersions(mine, minimum) >= 0) return false; + + const { response } = await dialog.showMessageBox({ + type: 'warning', + title: 'Update required', + message: 'This version of MeshBay can no longer connect', + detail: `This application is version ${mine}, and ${base} now requires ` + + `${minimum} or later.\n\nDownload the current version and install it ` + + 'over this one — your groups, keys and settings are kept.', + buttons: ['Download the update', 'Quit'], + defaultId: 0, + cancelId: 1, + }); + if (response === 0) await shell.openExternal(base); + return true; +} + // ── Lifecycle ─────────────────────────────────────────────────────────────── // One instance. Two would fight over the config file and the secrets blob, and @@ -1712,7 +1777,10 @@ if (!app.requestSingleInstanceLock()) { showFromTray(); }); - app.whenReady().then(() => { + app.whenReady().then(async () => { + // Before anything else is built. A window that opens and then cannot + // connect is the failure this replaces. + if (await refuseIfTooOld()) { app.quit(); return; } registerUiProtocol(); // Before ensureTray: buildTrayMenu reads `nodeService`, which registerBridge // assigns, so creating the tray after it means the Start/Stop entry is on -- cgit v1.2.3