summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 14:00:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 14:00:22 +0200
commit53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac (patch)
tree0d38403f86a0b96d5076c6404b471df7d1d41aad /packages
parentdee57df42a525cead93fa30b4e7fa38a489d5b11 (diff)
downloadmeshbay-53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac.tar.gz
feat: MNP 3.0 — a transfer needs a lease
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-client/package.json4
-rw-r--r--packages/meshbay-client/src/main.js70
-rw-r--r--packages/meshbay-common/pyproject.toml2
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py36
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py7
-rw-r--r--packages/meshbay-hub/pyproject.toml2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/__init__.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js8
-rw-r--r--packages/meshbay-hub/tests/test_client_version_gate.py141
-rw-r--r--packages/meshbay-hub/tests/test_versions_agree.py74
-rw-r--r--packages/meshbay-node/pyproject.toml2
-rw-r--r--packages/meshbay-node/src/meshbay_node/__init__.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/transfers.py83
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py29
-rw-r--r--packages/meshbay-node/tests/test_leaseless_reads.py85
16 files changed, 549 insertions, 15 deletions
diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json
index 00ed8a9..e88b2f0 100644
--- a/packages/meshbay-client/package.json
+++ b/packages/meshbay-client/package.json
@@ -1,7 +1,7 @@
{
"name": "meshbay-client",
- "version": "1.0.0",
- "description": "MeshBay desktop client — the interface ships with the application, not from the hub",
+ "version": "0.13.0",
+ "description": "MeshBay desktop client \u2014 the interface ships with the application, not from the hub",
"license": "AGPL-3.0-or-later",
"author": "MeshBay Team <team@meshbay.org>",
"homepage": "https://meshbay.org",
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
diff --git a/packages/meshbay-common/pyproject.toml b/packages/meshbay-common/pyproject.toml
index 13ade83..6a1370d 100644
--- a/packages/meshbay-common/pyproject.toml
+++ b/packages/meshbay-common/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "meshbay-common"
-version = "0.12.0"
+version = "0.13.0"
description = "MeshBay shared cryptographic primitives and protocol types"
requires-python = ">=3.12"
dependencies = [
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index b64a6f2..1aaa269 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -1,6 +1,6 @@
"""MeshBay common — shared crypto primitives and protocol types."""
-__version__ = "0.12.0"
+__version__ = "0.13.0"
# 0.2: added PING/PONG, and `before`/`has_more` on chat history. Both are
# additive — an 0.1 peer sends no `before` and gets the newest page, which is
# what it wanted — so this is a MINOR bump, not a MAJOR one.
@@ -133,5 +133,37 @@ __version__ = "0.12.0"
# `index_progress` (counters only — see daemon.py `_push_index_progress`), the
# admin and configuration acks, and the media-metadata replies. The index at
# rest and file content on the operator's disk are unchanged.
-MNP_VERSION = "2.0"
+# **3.0 (2026-09-09): a transfer needs a lease, and a peer that cannot ask for
+# one is refused at the handshake.**
+#
+# `transfer_open` / `transfer_close` / `transfer_state` carry the lease a
+# download or an upload runs under; `file_req` gains an optional `tr` and
+# `file_upload` gains one beside the `upload_id` already in clear. All three are
+# in clear, like `index_progress` and for the same stated reason: `tr` is
+# opaque, `bytes` and `chunks` are numbers, and there is no filename and no path
+# anywhere in them. Putting one there to make a log line prettier is exactly the
+# trade `groupbox.py` exists to refuse.
+#
+# **The messages are additive; the requirement is not, and that is what makes
+# this MAJOR.** A 2.0 client sends no `tr`, so it is a leaseless reader — and a
+# leaseless reader is either refused as soon as it opens a third file, or it is
+# not refused and transfers outside every cap the operator set. An opt-in switch
+# ("enforce leases only for clients that speak 3.0") leaves that branch
+# reachable on every node, which is finding C6's lesson — a transport that
+# accepted a bare JWT — one feature later. It was already refused once, for chat
+# encryption, on 2026-09-07.
+#
+# Browsing is deliberately **not** leased and never will be: not the poster
+# grid, not the covers, not opening a photo to look at it. That exemption is
+# bounded rather than open (`transfers.LeaselessReads`, two files in flight per
+# session), because an exemption with no bound is the leaseless branch under
+# another name.
+#
+# **What it costs, stated plainly.** The SPA is served by the hub, so a browser
+# picks up the new client on reload. The desktop client ships its own UI, so an
+# un-updated one is locked out — which is why `GET /v1/hub/version` carries
+# `client.minimum` and the client checks it *before* connecting, and says "this
+# version can no longer connect" rather than showing a handshake refusal nobody
+# can act on.
+MNP_VERSION = "3.0"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
index 188a8aa..65b4e85 100644
--- a/packages/meshbay-common/src/meshbay_common/handshake.py
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -78,7 +78,12 @@ HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1"
# handshake and then discovering that every message it sends is rejected and
# every message it receives is unreadable. A stated refusal is a bug report; a
# chat that quietly does not work is a support case.
-MNP_MIN_SUPPORTED = "2.0"
+# 3.0 (2026-09-09): a transfer runs under a lease, and a 2.x peer cannot ask for
+# one. Admitting it would mean either refusing it later, per file, in a way it
+# has no vocabulary to understand — or serving it outside every cap the operator
+# set, which makes the caps decoration. Neither is honest, so it is refused
+# here, with a code and a sentence.
+MNP_MIN_SUPPORTED = "3.0"
ROLE_CLIENT = "client"
ROLE_NODE = "node"
diff --git a/packages/meshbay-hub/pyproject.toml b/packages/meshbay-hub/pyproject.toml
index 9e5aace..012824c 100644
--- a/packages/meshbay-hub/pyproject.toml
+++ b/packages/meshbay-hub/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "meshbay-hub"
-version = "0.12.0"
+version = "0.13.0"
description = "MeshBay Hub — identity authority and group registry server"
requires-python = ">=3.12"
dependencies = [
diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py
index b713cf7..117aced 100644
--- a/packages/meshbay-hub/src/meshbay_hub/__init__.py
+++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py
@@ -1,3 +1,3 @@
"""MeshBay Hub — identity authority and group registry."""
-__version__ = "0.12.0"
+__version__ = "0.13.0"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 8223400..a48313d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -54,8 +54,21 @@ async def hub_pubkey():
# user "update to keep using this" before it becomes "this stopped working".
# Raise `minimum` only for a change a client genuinely cannot survive, and
# remember store review latency makes that expensive on Android.
-MIN_CLIENT_VERSION = "0.1.0"
-RECOMMENDED_CLIENT_VERSION = "0.1.0"
+# Raised on the MNP 3.0 flag day (2026-09-09). A client older than this speaks
+# MNP 2.x, cannot ask for a transfer lease, and is refused at the node's
+# handshake with `version_too_old` — a refusal in a protocol vocabulary that
+# surfaces as "the node will not talk to me". The client checks this field
+# before connecting and says something a person can act on instead.
+#
+# **This first raise does not reach the clients already installed**, and that is
+# understood rather than overlooked. `package.json` had drifted to "1.0.0" while
+# every other package was on 0.12.0, so an installed client announces a version
+# that sorts *above* this minimum and sails through the gate — then meets the
+# handshake refusal anyway. The operator is updating every client, node and hub
+# by hand for this flag day, which is what makes that acceptable exactly once.
+# The gate is in place for the next one, where it will work as intended.
+MIN_CLIENT_VERSION = "0.13.0"
+RECOMMENDED_CLIENT_VERSION = "0.13.0"
@router.get("/version")
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 23823ac..0b2fed5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -261,8 +261,12 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '2.0';
-const MNP_V_MIN = '1.0';
+const MNP_V = '3.0';
+// Raised with it on the 3.0 flag day. A node older than 3.0 cannot grant the
+// lease this client opens for every download and upload, so talking to one
+// would mean every transfer failing for a reason the person cannot act on.
+// Refusing it at the handshake says so once, in a sentence.
+const MNP_V_MIN = '3.0';
// Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's
// check_version): `version_too_old` means *we* are too old for it,
diff --git a/packages/meshbay-hub/tests/test_client_version_gate.py b/packages/meshbay-hub/tests/test_client_version_gate.py
new file mode 100644
index 0000000..69ca062
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_client_version_gate.py
@@ -0,0 +1,141 @@
+"""
+The desktop client refuses to start when the hub will no longer talk to it.
+
+The SPA is served by the hub, so a browser picks up a new client on reload. The
+desktop application **ships its own interface**, so on a 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. §12.3 of
+~/next/improve-downloads.md named this as the thing that had to exist before
+MNP 3.0 could ship.
+
+`compareVersions` and `refuseIfTooOld` are lifted out of `main.js` **as text**
+and executed against a modelled environment, on the rule this repo follows
+elsewhere: model the environment, never the code under test. The rest of
+`test_desktop_shell.py` can only read the source, because there is no npm here
+to launch Electron with; these two are ordinary functions and can be run.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client"
+MAIN = CLIENT / "src" / "main.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not MAIN.exists(),
+ reason="node or the desktop client sources are not available")
+
+
+def _lift(name: str) -> str:
+ src = MAIN.read_text()
+ cut = src[src.index(name):]
+ return cut[:cut.index("\n}\n") + 2]
+
+
+def _run(tmp_path, *, mine="1.1.0", hub_base="https://hub.example",
+ answer=None, status=200, throws=False):
+ """Drive the gate against one hub.
+
+ `answer` is what `/v1/hub/version` returns; None means the field is absent
+ entirely, which is what an older hub sends.
+ """
+ script = tmp_path / "gate.mjs"
+ script.write_text(f"""
+const out = {{ dialogs: 0, opened: null }};
+const config = {{ hubBase: {json.dumps(hub_base)} }};
+const app = {{ getVersion: () => {json.dumps(mine)} }};
+const dialog = {{
+ showMessageBox: async () => {{ out.dialogs += 1; return {{ response: 0 }}; }},
+}};
+const shell = {{ openExternal: async (u) => {{ out.opened = u; }} }};
+globalThis.fetch = async () => {{
+ if ({json.dumps(throws)}) throw new Error('unreachable');
+ return {{ ok: {json.dumps(status)} === 200,
+ json: async () => ({json.dumps(answer)}) }};
+}};
+""" + _lift("function compareVersions") + _lift("async function refuseIfTooOld") + """
+out.refused = await refuseIfTooOld();
+out.compare = [
+ compareVersions('1.0.0', '1.1.0'),
+ compareVersions('1.1.0', '1.1.0'),
+ compareVersions('1.2.0', '1.1.0'),
+ compareVersions('1.10.0', '1.9.0'),
+ compareVersions('1.1', '1.1.0'),
+ compareVersions('nonsense', '1.1.0'),
+];
+console.log(JSON.stringify(out));
+""")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+OK = {"client": {"minimum": "1.1.0", "recommended": "1.1.0"}}
+
+
+# ── the comparison ──────────────────────────────────────────────────────────
+
+def test_versions_compare_by_number_and_not_by_string(tmp_path):
+ """`1.10.0` is newer than `1.9.0`, which string comparison gets backwards —
+ and that mistake locks out exactly the people who did update."""
+ assert _run(tmp_path, answer=OK)["compare"] == [-1, 0, 1, 1, 0, 0]
+
+
+# ── the gate ────────────────────────────────────────────────────────────────
+
+def test_a_client_older_than_the_minimum_is_stopped(tmp_path):
+ out = _run(tmp_path, mine="1.0.0", answer=OK)
+ assert out["refused"] is True
+ assert out["dialogs"] == 1, "it stopped without saying why"
+ assert out["opened"] == "https://hub.example", (
+ "the offer to download the update led nowhere")
+
+
+def test_a_current_client_starts_normally(tmp_path):
+ out = _run(tmp_path, mine="1.1.0", answer=OK)
+ assert out["refused"] is False
+ assert out["dialogs"] == 0
+
+
+def test_a_newer_client_is_not_stopped(tmp_path):
+ """A development build ahead of the hub is not a reason to refuse to open
+ the application."""
+ assert _run(tmp_path, mine="2.0.0", answer=OK)["refused"] is False
+
+
+def test_an_unreachable_hub_is_not_too_old(tmp_path):
+ """A hub that is down, a laptop with no network, a captive portal. Treating
+ any of those as "you are out of date" would make an offline start
+ impossible for ever, and would do it at the worst moment."""
+ assert _run(tmp_path, mine="1.0.0", throws=True)["refused"] is False
+ assert _run(tmp_path, mine="1.0.0", status=503, answer=OK)["refused"] is False
+
+
+def test_a_hub_that_states_no_minimum_stops_nothing(tmp_path):
+ """An older hub answers without the field. Absent must read as "no opinion",
+ never as a refusal."""
+ assert _run(tmp_path, mine="0.0.1", answer={"hub": "1.2.3"})["refused"] is False
+
+
+def test_a_first_run_with_no_hub_yet_is_not_stopped(tmp_path):
+ """There is nothing to ask, and the first-run screen is where the address
+ gets typed."""
+ assert _run(tmp_path, mine="0.0.1", hub_base="", answer=OK)["refused"] is False
+
+
+# ── where it is called ──────────────────────────────────────────────────────
+
+def test_the_gate_runs_before_the_window_is_built():
+ """A window that opens and then cannot connect is the failure this
+ replaces, so the order is the whole point."""
+ src = MAIN.read_text()
+ ready = src[src.index("app.whenReady().then("):]
+ ready = ready[:ready.index("createWindow();")]
+ assert "await refuseIfTooOld()" in ready, (
+ "the version check does not run before the window is created")
+ assert "app.quit()" in ready
diff --git a/packages/meshbay-hub/tests/test_versions_agree.py b/packages/meshbay-hub/tests/test_versions_agree.py
new file mode 100644
index 0000000..4466c93
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_versions_agree.py
@@ -0,0 +1,74 @@
+"""
+Every package in this repository carries the same version.
+
+They are built, deployed and updated together — hub, node, common and the
+desktop client — so a version that differs is not a statement about that
+package, it is a mistake nobody has noticed yet.
+
+**Found on 2026-09-09, on the MNP 3.0 flag day.** `meshbay-client`'s
+`package.json` had drifted to `1.0.0` while every Python package was on
+`0.12.0`. That was invisible until the hub started publishing a minimum client
+version and the client started comparing itself against it — at which point an
+installed client announcing `1.0.0` sorted *above* a minimum of `0.13.0` and
+walked straight through the gate meant to stop it. A version nobody reads is
+free to be wrong; the moment something compares it, it is load-bearing.
+"""
+
+import json
+import re
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[3]
+PACKAGES = ROOT / "packages"
+
+
+def _python_versions() -> dict[str, str]:
+ found = {}
+ for pyproject in sorted(PACKAGES.glob("*/pyproject.toml")):
+ m = re.search(r'^version = "([^"]+)"', pyproject.read_text(), re.M)
+ if m:
+ found[f"{pyproject.parent.name}/pyproject.toml"] = m.group(1)
+ for init in sorted(PACKAGES.glob("*/src/*/__init__.py")):
+ m = re.search(r'^__version__ = "([^"]+)"', init.read_text(), re.M)
+ if m:
+ found[f"{init.parent.name}/__init__.py"] = m.group(1)
+ return found
+
+
+def _client_version() -> str | None:
+ pkg = PACKAGES / "meshbay-client" / "package.json"
+ if not pkg.exists():
+ return None
+ return json.loads(pkg.read_text()).get("version")
+
+
+@pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present")
+def test_every_package_carries_the_same_version():
+ versions = _python_versions()
+ assert versions, "no package versions found at all — has the layout moved?"
+ client = _client_version()
+ if client is not None:
+ versions["meshbay-client/package.json"] = client
+ distinct = sorted(set(versions.values()))
+ assert len(distinct) == 1, (
+ "packages disagree about the version: "
+ + ", ".join(f"{k}={v}" for k, v in sorted(versions.items())))
+
+
+@pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present")
+def test_the_hub_will_not_refuse_the_client_it_ships_with():
+ """`MIN_CLIENT_VERSION` is compared against a client's own version, so a
+ minimum above the version being built would lock out the very build being
+ released — the one failure this field can cause that nobody would think to
+ test for by hand."""
+ from meshbay_hub.api.hub import MIN_CLIENT_VERSION
+
+ client = _client_version()
+ if client is None:
+ pytest.skip("desktop client sources not present")
+ as_numbers = lambda v: [int(n) for n in v.split(".")] # noqa: E731
+ assert as_numbers(MIN_CLIENT_VERSION) <= as_numbers(client), (
+ f"the hub requires client {MIN_CLIENT_VERSION} but this tree builds "
+ f"{client}")
diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml
index 7cd0ca9..aa13d1e 100644
--- a/packages/meshbay-node/pyproject.toml
+++ b/packages/meshbay-node/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "meshbay-node"
-version = "0.12.0"
+version = "0.13.0"
description = "MeshBay Node — local file host, streaming server, and group daemon"
requires-python = ">=3.12"
dependencies = [
diff --git a/packages/meshbay-node/src/meshbay_node/__init__.py b/packages/meshbay-node/src/meshbay_node/__init__.py
index 1bc8c9f..182ed32 100644
--- a/packages/meshbay-node/src/meshbay_node/__init__.py
+++ b/packages/meshbay-node/src/meshbay_node/__init__.py
@@ -1,3 +1,3 @@
"""MeshBay Node — local file host, streaming server, and group daemon."""
-__version__ = "0.12.0"
+__version__ = "0.13.0"
diff --git a/packages/meshbay-node/src/meshbay_node/transfers.py b/packages/meshbay-node/src/meshbay_node/transfers.py
index dd5da5c..2185547 100644
--- a/packages/meshbay-node/src/meshbay_node/transfers.py
+++ b/packages/meshbay-node/src/meshbay_node/transfers.py
@@ -403,3 +403,86 @@ class TransferSlots:
return " ".join(
f"{kind[0]}={p[kind]['in_use']}/{p[kind]['cap']}"
f"(q{p[kind]['queued']})" for kind in KINDS)
+
+
+# ── Reads that carry no lease ───────────────────────────────────────────────
+
+# How many distinct files one session may be reading at once without a lease.
+#
+# Browsing a group is never subject to a transfer slot — not the poster grid,
+# not the covers, not opening a photo or a PDF to look at it. A member must be
+# able to browse a group that is at capacity exactly as they browse an idle one.
+# That is a requirement, and §3.4 of ~/next/improve-downloads.md satisfies it
+# structurally: a transfer is what the transfers widget shows, and nothing else
+# takes a slot.
+#
+# But "not leased" cannot mean "unbounded", or a client that simply omits `tr`
+# transfers outside every cap and the caps are decoration. Two, because a viewer
+# looks at *one* file — one photo, one document — and the second is there so
+# that prefetching the next photo stays possible.
+#
+# Deliberately a count of files and not a byte budget: a RAW photo out of a
+# camera is 60-80 MB and is browsing, a 40 MB archive is a download, and no
+# size threshold separates them. What separates them is which function asked.
+#
+# What it costs, stated plainly: a client that lies — labelling a bulk download
+# as a view — gets two files at a time instead of its member cap. That is the
+# residual, it is bounded, it is audited, and it is the same kind of statement
+# as the cap itself. **This is a fairness control among cooperating clients**,
+# not a defence against a member determined to saturate a node's disk. The
+# answer to that member is `member revoke`.
+MAX_LEASELESS_IN_FLIGHT = 2
+
+# A leaseless read has no "close" message, so it ends when the last chunk goes
+# out — or, when a viewer is closed mid-file and simply stops asking, when it
+# has been quiet this long.
+LEASELESS_IDLE_SECS = 60
+
+
+class LeaselessReads:
+ """
+ The files one session is reading without a lease, and the bound on them.
+
+ Per session rather than per member: this is not a resource pool, it is a
+ ceiling on what one connection can do while claiming to be browsing. A
+ member with three tabs open is browsing in three tabs, which is fine.
+ """
+
+ def __init__(self, limit: int = MAX_LEASELESS_IN_FLIGHT,
+ idle: float = LEASELESS_IDLE_SECS) -> None:
+ self.limit = limit
+ self.idle = idle
+ self._seen: dict[str, float] = {}
+
+ def admit(self, file_id: str, now: float | None = None) -> bool:
+ """May this session read `file_id` without a lease right now?
+
+ True for a file it is already reading, whatever the count: refusing a
+ chunk halfway through a photo because the limit moved would be worse
+ than never having admitted it.
+ """
+ when = time.monotonic() if now is None else now
+ self._expire(when)
+ if file_id in self._seen:
+ self._seen[file_id] = when
+ return True
+ if len(self._seen) >= self.limit:
+ return False
+ self._seen[file_id] = when
+ return True
+
+ def finish(self, file_id: str) -> None:
+ """The last chunk went out; the slot is free at once rather than in a
+ minute."""
+ self._seen.pop(file_id, None)
+
+ def _expire(self, now: float) -> None:
+ # A viewer closed mid-file stops asking and says nothing. Without this
+ # the session would carry two dead entries and refuse every later
+ # preview, which is the bound turning into a bug.
+ for file_id, last in list(self._seen.items()):
+ if now - last > self.idle:
+ del self._seen[file_id]
+
+ def __len__(self) -> int:
+ return len(self._seen)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 9de799c..507650a 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -430,6 +430,11 @@ class WebRTCPeerSession:
self._admin_ops: dict[str, dict] = {} # op_id → pending admin operation
# Uploads in progress live in the group context, not here: see
# `_partial_uploads` and `uploads.py`.
+ #
+ # Leaseless reads, though, *are* this connection's: the bound is on what
+ # one session may do while claiming to be browsing, not a pool shared
+ # between them. Three tabs open is browsing in three tabs.
+ self._leaseless = transfers_mod.LeaselessReads()
# Diagnostics only (_WEBRTC_TRACE): when the last DataChannel message
# arrived, so the heartbeat can report silence duration.
self._last_msg_at: float = 0.0
@@ -3689,6 +3694,25 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "File not on disk"})
return
+ # A real index entry, asked for without a lease: browsing, or a client
+ # helping itself to the whole library outside every cap.
+ #
+ # Both look identical here — which is why the bound is a small count of
+ # files rather than a judgement about what the read is for. Thumbnails,
+ # posters and cover art never reach this line: they resolve through
+ # `_try_serve_thumbnail` above, out of a cache the node built itself,
+ # and are never leased, never counted, never queued.
+ if not tr:
+ if not self._leaseless.admit(str(file_id)):
+ self._send({
+ "type": "error",
+ "detail": "Too many files open at once without a transfer. "
+ "Download this one instead of previewing it.",
+ "code": "transfer_required",
+ "file_id": file_id,
+ })
+ return
+
log.debug("dl: req file=%s chunk=%s buffered=%s",
file_id[:12], chunk_index,
getattr(self._channel, "bufferedAmount", "?"))
@@ -3713,6 +3737,11 @@ class WebRTCPeerSession:
getattr(self._channel, "bufferedAmount", "?"))
if chunk_index == 0:
self._audit("file_download", entry.name)
+ # The last chunk is the only "close" a leaseless read has. Without this
+ # the session carries the entry until it goes idle, and the person who
+ # just looked at two photos cannot look at a third for a minute.
+ if not tr and (chunk_index + 1) * CHUNK_SIZE >= entry.size:
+ self._leaseless.finish(str(file_id))
@staticmethod
async def _fetch_and_cache_poster(media_cache, tmdb_client, poster_path: str | None) -> str | None:
diff --git a/packages/meshbay-node/tests/test_leaseless_reads.py b/packages/meshbay-node/tests/test_leaseless_reads.py
new file mode 100644
index 0000000..70fd24f
--- /dev/null
+++ b/packages/meshbay-node/tests/test_leaseless_reads.py
@@ -0,0 +1,85 @@
+"""
+Browsing is never subject to a transfer slot — and is not unbounded either.
+
+**Operator decision, 2026-09-08:** a member must be able to browse a group that
+is at capacity exactly as they browse an idle one. Not the poster grid, not the
+covers, not opening a photo or a PDF to look at it. §3.4 of
+~/next/improve-downloads.md satisfies that structurally: a transfer is what the
+transfers widget shows, and nothing else takes a slot.
+
+But "not leased" cannot mean "unbounded". With MNP 3.0 making leases
+compulsory, a client that simply omits `tr` would otherwise transfer outside
+every cap, and the caps would be decoration — the leaseless branch left
+reachable is finding C6's lesson (a transport that accepted a bare token) one
+feature later.
+
+So a leaseless read is bounded by a small count of *files in flight*, not by
+bytes: a RAW photo out of a camera is 60–80 MB and is browsing, a 40 MB archive
+is a download, and no size threshold separates them. What separates them is
+which function asked.
+"""
+
+from meshbay_node.transfers import (
+ LEASELESS_IDLE_SECS, MAX_LEASELESS_IN_FLIGHT, LeaselessReads,
+)
+
+
+def test_a_viewer_looking_at_one_file_is_never_refused():
+ reads = LeaselessReads()
+ for chunk in range(20):
+ assert reads.admit("photo-1", now=float(chunk)) is True
+
+
+def test_a_second_file_is_allowed_so_prefetching_stays_possible():
+ """One is what a viewer needs; two is so the photo viewer can fetch the
+ next one while showing this one."""
+ reads = LeaselessReads()
+ assert reads.admit("photo-1", now=0.0) is True
+ assert reads.admit("photo-2", now=0.0) is True
+
+
+def test_a_third_file_is_refused():
+ reads = LeaselessReads()
+ reads.admit("a", now=0.0)
+ reads.admit("b", now=0.0)
+ assert reads.admit("c", now=0.0) is False
+
+
+def test_a_file_already_being_read_is_never_cut_off():
+ """Even once the limit is reached. Refusing a chunk halfway through a photo
+ because the count moved would be worse than never having admitted it — the
+ viewer would show half an image and no error anyone can act on."""
+ reads = LeaselessReads()
+ reads.admit("a", now=0.0)
+ reads.admit("b", now=0.0)
+ assert reads.admit("c", now=0.0) is False
+ assert reads.admit("a", now=1.0) is True
+
+
+def test_finishing_one_frees_it_at_once():
+ """The last chunk is the only "close" a leaseless read has. Waiting for the
+ idle timeout instead would mean somebody who looked at two photos cannot
+ look at a third for a minute."""
+ reads = LeaselessReads()
+ reads.admit("a", now=0.0)
+ reads.admit("b", now=0.0)
+ reads.finish("a")
+ assert reads.admit("c", now=0.0) is True
+
+
+def test_a_viewer_closed_mid_file_does_not_hold_its_place_for_ever():
+ """It stops asking and says nothing — there is no message for "I closed the
+ tab". Without the idle expiry the session would carry two dead entries and
+ refuse every later preview, which is the bound turning into a bug."""
+ reads = LeaselessReads()
+ reads.admit("a", now=0.0)
+ reads.admit("b", now=0.0)
+ assert reads.admit("c", now=1.0) is False
+ assert reads.admit("c", now=LEASELESS_IDLE_SECS + 2) is True
+
+
+def test_the_bound_is_two():
+ """Stated here so that changing it is a decision rather than a typo: it is
+ the number §3.4.1 argues for, and the argument is about viewers, not about
+ tuning."""
+ assert MAX_LEASELESS_IN_FLIGHT == 2