aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
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