summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js8
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py66
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py60
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py85
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py53
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py41
-rw-r--r--packages/meshbay-node/tests/test_chat_is_bounded.py2
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py5
-rw-r--r--packages/meshbay-node/tests/test_node_settings_defaults.py85
-rw-r--r--packages/meshbay-node/tests/test_transfer_settings.py6
-rw-r--r--packages/meshbay-node/tests/test_upload_size_cap.py152
21 files changed, 510 insertions, 73 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 9d9319d..2d925e1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -886,6 +886,8 @@ export default {
'node.setting_max_downloads': 'Max. gleichzeitige Downloads',
'node.setting_max_uploads': 'Max. gleichzeitige Uploads',
'node.setting_transcode': 'Transcode incompatible video',
+ 'node.setting_max_upload': 'Größter einzelner Upload',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'hours',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'On',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a928a52..690a70b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1038,6 +1038,8 @@ export default {
'node.setting_max_downloads': 'Max concurrent downloads',
'node.setting_max_uploads': 'Max concurrent uploads',
'node.setting_transcode': 'Transcode incompatible video',
+ 'node.setting_max_upload': 'Largest single upload',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'hours',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'On',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 2e6ea26..c48f942 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -881,6 +881,8 @@ export default {
'node.setting_max_downloads': 'Descargas simultáneas máximas',
'node.setting_max_uploads': 'Subidas simultáneas máximas',
'node.setting_transcode': 'Transcode incompatible video',
+ 'node.setting_max_upload': 'Subida individual máxima',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'hours',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'On',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 5e6f258..6d148d6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -884,6 +884,8 @@ export default {
'node.setting_max_downloads': 'Téléchargements simultanés max',
'node.setting_max_uploads': 'Téléversements simultanés max',
'node.setting_transcode': 'Transcoder les vidéos incompatibles',
+ 'node.setting_max_upload': 'Taille maximale d\'un envoi',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'heures',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'Activé',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index ba594d2..c619d61 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -883,6 +883,8 @@ export default {
'node.setting_max_downloads': 'Download simultanei massimi',
'node.setting_max_uploads': 'Caricamenti simultanei massimi',
'node.setting_transcode': 'Transcode incompatible video',
+ 'node.setting_max_upload': 'Caricamento singolo massimo',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'hours',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'On',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 9bbafb2..eac90dc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -871,6 +871,8 @@ export default {
'node.setting_max_downloads': '同時ダウンロードの上限',
'node.setting_max_uploads': '同時アップロードの上限',
'node.setting_transcode': 'Transcode incompatible video',
+ 'node.setting_max_upload': '1ファイルあたりの上限',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'hours',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'On',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 8f28d0c..65bf4f5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -885,6 +885,8 @@ export default {
'node.setting_max_downloads': 'Max. gelijktijdige downloads',
'node.setting_max_uploads': 'Max. gelijktijdige uploads',
'node.setting_transcode': 'Transcode incompatible video',
+ 'node.setting_max_upload': 'Grootste enkele upload',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'hours',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'On',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index fadf622..264d442 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -903,6 +903,8 @@ export default {
'node.setting_max_downloads': 'Maks. równoczesnych pobierań',
'node.setting_max_uploads': 'Maks. równoczesnych wysyłek',
'node.setting_transcode': 'Transcode incompatible video',
+ 'node.setting_max_upload': 'Największa pojedyncza wysyłka',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'hours',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'On',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 39da2b2..0fbfb1e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -882,6 +882,8 @@ export default {
'node.setting_max_downloads': 'Máximo de downloads simultâneos',
'node.setting_max_uploads': 'Máximo de envios simultâneos',
'node.setting_transcode': 'Transcode incompatible video',
+ 'node.setting_max_upload': 'Maior envio individual',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'hours',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'On',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 8639592..ab75b11 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -858,6 +858,8 @@ export default {
'node.setting_max_downloads': '最大同时下载数',
'node.setting_max_uploads': '最大同时上传数',
'node.setting_transcode': 'Transcode incompatible video',
+ 'node.setting_max_upload': '单个上传的最大大小',
+ 'node.setting_unit_gb': 'GB',
'node.setting_unit_hours': 'hours',
'node.setting_unit_minutes': 'min',
'node.setting_on': 'On',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index 0f75edc..2edcbab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -1068,6 +1068,14 @@ export function NodePage({ groups, token, username }) {
</div>
</label>
<label class="node-setting">
+ <span class="node-setting-label">${t('node.setting_max_upload')}</span>
+ <div class="node-setting-input">
+ <input type="number" min="0.1" step="0.5" value=${editSettings.max_upload_gb}
+ onInput=${e => setEditSettings(s => ({...s, max_upload_gb: parseFloat(e.target.value) || 0.1}))} />
+ <span class="node-setting-unit">${t('node.setting_unit_gb')}</span>
+ </div>
+ </label>
+ <label class="node-setting">
<span class="node-setting-label">${t('node.setting_transcode')}</span>
<div class="node-setting-input">
<input type="checkbox" checked=${editSettings.transcode_incompatible_video}
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index 086d800..2ae3c7c 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -67,6 +67,13 @@ max_concurrent_streams = 8
max_concurrent_downloads = 8
max_concurrent_uploads = 8
+# The largest single file a member may upload to this node, in GB. It is this
+# machine's disk that fills, so the ceiling is the operator's to set: lower it
+# on a small disk, raise it for a library of films. Fractions are allowed
+# (0.5 = 512 MB). A file past it is refused at the chunk that crosses it and
+# the partial file is deleted.
+max_upload_gb = 8
+
# HEVC sources have no browser decoder on most platforms, so streaming one is
# transcoded to H264 rather than the usual free copy — real CPU per viewer.
# Set to false only if every viewer's client is known to decode HEVC itself.
@@ -181,6 +188,11 @@ class NodeConfig:
# answer "server busy". See meshbay_node/transfers.py.
max_concurrent_downloads: int = 8
max_concurrent_uploads: int = 8
+ # The per-file upload ceiling, in GB — the one limit here that bounds a
+ # member's writes to the operator's disk rather than this node's own
+ # concurrency. There is still no aggregate quota (MESHBAY_DESIGN.md
+ # §15.3), so this is what stands between a writable root and a full disk.
+ max_upload_gb: float = 8.0
# HEVC (and any future codec in media_probe.py's
# BROWSER_INCOMPATIBLE_VIDEO_CODECS) has no decoder in most browsers, so
# streaming it needs a real re-encode to H264 rather than the usual free
@@ -301,6 +313,28 @@ def _positive(value: object, default: int, name: str) -> int:
return n
+def _positive_float(value: object, default: float, name: str) -> float:
+ """A size that must be greater than zero, or the default with a word about it.
+
+ Separate from `_positive` because this one is a quantity, not a count: half
+ a gigabyte is a legitimate ceiling on a small disk, and rounding it to zero
+ would refuse every upload with nothing in the log to say why.
+ """
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+ log.warning("%s = %r is not a size — using %g", name, value, default)
+ return default
+ try:
+ n = float(value)
+ except (TypeError, ValueError):
+ log.warning("%s = %r is not a number — using %g", name, value, default)
+ return default
+ if n <= 0:
+ log.warning("%s = %g would refuse every upload — using %g",
+ name, n, default)
+ return default
+ return n
+
+
def _read_roots(group: dict) -> list[RootSpec]:
"""
A group's roots, from `[[groups.roots]]` or from the legacy `shared_dir`.
@@ -332,6 +366,35 @@ def _read_roots(group: dict) -> list[RootSpec]:
return specs
+def node_settings_defaults(nd: NodeConfig | None = None) -> dict:
+ """
+ The `node.toml` side of every setting the roster resolves.
+
+ One function because there were three copies of this dict written by hand
+ and they disagreed. The daemon's left out both transfer pools, so on a node
+ whose operator had never touched the panel they resolved to None, were
+ written back onto the config, and the transport fell through to its own
+ defaults — `node.toml` parsed, validated, and then ignored.
+
+ `test_node_settings_defaults.py` holds this against
+ `Roster.node_setting_keys()`, because a key missing here raises nothing
+ anywhere: it is a setting that stops working quietly.
+ """
+ nd = nd or NodeConfig()
+ return {
+ "invite_ttl_hours": nd.invite_ttl_hours,
+ "pair_ttl_hours": nd.pair_ttl_hours,
+ "device_request_ttl_minutes": nd.device_request_ttl_minutes,
+ "max_concurrent_streams": nd.max_concurrent_streams,
+ "max_concurrent_downloads": nd.max_concurrent_downloads,
+ "max_concurrent_uploads": nd.max_concurrent_uploads,
+ "max_upload_gb": nd.max_upload_gb,
+ "transcode_incompatible_video": nd.transcode_incompatible_video,
+ "stun_servers": nd.stun_servers or list(DEFAULT_STUN_SERVERS),
+ "ice_interfaces": nd.ice_interfaces,
+ }
+
+
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
"""
Load config from TOML file. Supports both single [group] and
@@ -368,6 +431,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg.node.max_concurrent_uploads = _positive(
nd.get("max_concurrent_uploads", cfg.node.max_concurrent_uploads),
cfg.node.max_concurrent_uploads, "max_concurrent_uploads")
+ cfg.node.max_upload_gb = _positive_float(
+ nd.get("max_upload_gb", cfg.node.max_upload_gb),
+ cfg.node.max_upload_gb, "max_upload_gb")
cfg.node.transcode_incompatible_video = bool(
nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video))
cfg.node.hardware_video_encode = bool(
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index ad4a513..3492d03 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -293,18 +293,10 @@ class NodeDaemon:
# Apply any roster overrides to node config (panel-edited values
# take precedence over node.toml defaults).
- from meshbay_node.config import DEFAULT_STUN_SERVERS
+ from meshbay_node.config import node_settings_defaults
nd = self._config.node
- defaults = {
- "invite_ttl_hours": nd.invite_ttl_hours,
- "pair_ttl_hours": nd.pair_ttl_hours,
- "device_request_ttl_minutes": nd.device_request_ttl_minutes,
- "max_concurrent_streams": nd.max_concurrent_streams,
- "transcode_incompatible_video": nd.transcode_incompatible_video,
- "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS),
- "ice_interfaces": nd.ice_interfaces,
- }
- effective = await self._roster.node_settings(defaults)
+ effective = await self._roster.node_settings(
+ node_settings_defaults(nd))
for k, v in effective.items():
setattr(nd, k, v)
@@ -541,6 +533,7 @@ class NodeDaemon:
max_concurrent_streams=self._config.node.max_concurrent_streams,
max_concurrent_downloads=self._config.node.max_concurrent_downloads,
max_concurrent_uploads=self._config.node.max_concurrent_uploads,
+ max_upload_gb=self._config.node.max_upload_gb,
transcode_incompatible_video=self._config.node.transcode_incompatible_video,
stun_servers=self._config.node.stun_servers or None,
)
@@ -1976,9 +1969,10 @@ def main() -> None:
"| chat status|rotate|encrypt-history|prune "
"| denylist show|clear "
"| stun list|add|remove|reset "
- "| transfers show|set|per-member: live transfer "
- "slots, the node-wide caps, and how many one "
- "member may run at once in a group "
+ "| transfers show|set|max-size|per-member: live "
+ "transfer slots, the node-wide caps, the largest "
+ "single upload, and how many one member may run "
+ "at once in a group "
"| reload: re-read node.toml (hot; systemd or the "
"loopback API) | restart-daemon: restart the node "
"(systemd unit, the Windows autostart launcher, or the "
@@ -1996,13 +1990,14 @@ def main() -> None:
"init|rotate for gek; "
"list|rm for file; rematch for video; show|clear for "
"denylist; list|add|remove|reset for stun; "
- "show|set|per-member for transfers; "
+ "show|set|max-size|per-member for transfers; "
"install|remove|start|stop|status for autostart and "
"for service")
parser.add_argument("target", nargs="?",
help="username for member invite|revoke|unpin; group name "
"for group add; file id for file rm; identifier for "
- "denylist clear; download cap for transfers set")
+ "denylist clear; download cap for transfers set; "
+ "size in GB for transfers max-size")
parser.add_argument("value", nargs="?",
help="the second value where a verb takes two: the "
"upload cap for transfers set")
@@ -2738,6 +2733,14 @@ def main() -> None:
for kind, pool in out.get("pools", {}).items():
print(f" {kind:<9} {pool['in_use']}/{pool['cap']} in use, "
f"{pool['queued']} queued (node-wide)")
+ # From the daemon, not from node.toml: a change made on the Node
+ # page is live before it is written back, and the number to print
+ # is the one being enforced. The local file is the fallback rather
+ # than a literal, so there is no second copy of the default here.
+ settings = _daemon_api(cfg, "/api/node-settings")
+ gb = settings.get("max_upload_gb") or cfg.node.max_upload_gb
+ print(f"\n largest single upload: {gb:g} GB per file "
+ f"(meshbay-node transfers max-size <GB>)")
# Per group, because that is the cap that decides how many one
# person runs at once — and it is not the node-wide number. An
# operator raising `transfers set 8 8` and still seeing two at a
@@ -2787,6 +2790,29 @@ def main() -> None:
f"(applied now, and kept in node.toml)")
return
+ if sub == "max-size":
+ # The largest single file a member may upload here. Not a
+ # concurrency cap like `set` — it is the one limit that bounds what
+ # a member writes to the operator's disk, which is why it lives
+ # beside them rather than under a verb of its own.
+ if not args.target:
+ print("usage: meshbay-node transfers max-size <GB>")
+ sys.exit(1)
+ try:
+ gb = float(args.target)
+ except ValueError:
+ print("error: the size must be a number of GB (e.g. 8, or 0.5)")
+ sys.exit(1)
+ if gb <= 0:
+ print("error: a ceiling of zero is not 'unlimited'; it would "
+ "refuse every upload. Make the root read-only instead.")
+ sys.exit(1)
+ _daemon_api(cfg, "/api/node-settings", method="PUT",
+ body={"max_upload_gb": gb})
+ print(f"largest single upload: {gb:g} GB per file "
+ f"(applied now, and kept in node.toml)")
+ return
+
if sub == "per-member":
# How many transfers ONE member may run at once in this group. Not
# the same knob as `set`, which is the machine's total — and the
@@ -2820,7 +2846,7 @@ def main() -> None:
return
print("usage: meshbay-node transfers show|set <downloads> <uploads>|"
- "per-member <downloads> <uploads> [--group NAME]")
+ "max-size <GB>|per-member <downloads> <uploads> [--group NAME]")
sys.exit(1)
if args.command == "file":
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 98f67c5..ca1b87d 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -40,6 +40,7 @@ from meshbay_common.crypto import (
from meshbay_node.config import DEFAULT_CONFIG_PATH
from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
from meshbay_node.roots import RootError, RootSet, off_disk
+from meshbay_node.roster import Roster
log = logging.getLogger(__name__)
@@ -623,17 +624,11 @@ async def list_groups(state: dict) -> dict:
members = await roster.list_members()
has_operator = any(m["role"] == "operator" and m["status"] == "active"
for m in members)
- from meshbay_node.config import DEFAULT_STUN_SERVERS
- nd = config.node if config else None
- defaults = {
- "invite_ttl_hours": nd.invite_ttl_hours if nd else 168,
- "pair_ttl_hours": nd.pair_ttl_hours if nd else 24,
- "device_request_ttl_minutes": nd.device_request_ttl_minutes if nd else 60,
- "max_concurrent_streams": nd.max_concurrent_streams if nd else 8,
- "transcode_incompatible_video": nd.transcode_incompatible_video if nd else True,
- "stun_servers": nd.stun_servers if nd and nd.stun_servers else list(DEFAULT_STUN_SERVERS),
- "ice_interfaces": nd.ice_interfaces if nd else [],
- }
+ from meshbay_node.config import node_settings_defaults
+ # No config (a test, an unconfigured node) falls back to NodeConfig()'s own
+ # values rather than to numbers repeated here, which is the copy this used
+ # to be: it was missing three settings and reported them as null.
+ defaults = node_settings_defaults(config.node if config else None)
if roster:
settings = await roster.node_settings(defaults)
else:
@@ -1288,23 +1283,33 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict:
# ── Node settings ────────────────────────────────────────────────────────────
+# What `set_node_settings` accepts, and how each value is validated. A module
+# constant so a test can hold its key set against `Roster.node_setting_keys()`:
+# this is the third list of the same settings, and the first two had already
+# drifted apart once — the reader's defaults covered fewer settings than the
+# resolver answered for, which is how node.toml's transfer pools came to be
+# parsed and then ignored. The kinds here are the *writer's* validation and
+# deliberately not the resolver's coercions.
+NODE_SETTING_WRITERS: dict[str, tuple[str, str]] = {
+ "invite_ttl_hours": ("int", Roster.SETTING_INVITE_TTL),
+ "pair_ttl_hours": ("int", Roster.SETTING_PAIR_TTL),
+ "device_request_ttl_minutes": ("int", Roster.SETTING_DEVICE_TTL),
+ "max_concurrent_streams": ("int", Roster.SETTING_MAX_STREAMS),
+ "max_concurrent_downloads": ("int", Roster.SETTING_MAX_DOWNLOADS),
+ "max_concurrent_uploads": ("int", Roster.SETTING_MAX_UPLOADS),
+ "max_upload_gb": ("size", Roster.SETTING_MAX_UPLOAD_GB),
+ "transcode_incompatible_video": ("bool", Roster.SETTING_TRANSCODE),
+ "stun_servers": ("stun_list", Roster.SETTING_STUN_SERVERS),
+ "ice_interfaces": ("list", Roster.SETTING_ICE_INTERFACES),
+}
+
+
async def get_node_settings(state: dict) -> dict:
"""Return current effective node settings."""
- from meshbay_node.config import DEFAULT_STUN_SERVERS
+ from meshbay_node.config import node_settings_defaults
roster = _roster(state)
config = _config(state)
- nd = config.node
- defaults = {
- "invite_ttl_hours": nd.invite_ttl_hours,
- "pair_ttl_hours": nd.pair_ttl_hours,
- "device_request_ttl_minutes": nd.device_request_ttl_minutes,
- "max_concurrent_streams": nd.max_concurrent_streams,
- "max_concurrent_downloads": nd.max_concurrent_downloads,
- "max_concurrent_uploads": nd.max_concurrent_uploads,
- "transcode_incompatible_video": nd.transcode_incompatible_video,
- "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS),
- "ice_interfaces": nd.ice_interfaces,
- }
+ defaults = node_settings_defaults(config.node)
if roster:
return await roster.node_settings(defaults)
return defaults
@@ -1316,17 +1321,7 @@ async def set_node_settings(state: dict, settings: dict) -> dict:
nd = config.node
conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
- allowed_keys = {
- "invite_ttl_hours": ("int", roster.SETTING_INVITE_TTL),
- "pair_ttl_hours": ("int", roster.SETTING_PAIR_TTL),
- "device_request_ttl_minutes": ("int", roster.SETTING_DEVICE_TTL),
- "max_concurrent_streams": ("int", roster.SETTING_MAX_STREAMS),
- "max_concurrent_downloads": ("int", roster.SETTING_MAX_DOWNLOADS),
- "max_concurrent_uploads": ("int", roster.SETTING_MAX_UPLOADS),
- "transcode_incompatible_video": ("bool", roster.SETTING_TRANSCODE),
- "stun_servers": ("stun_list", roster.SETTING_STUN_SERVERS),
- "ice_interfaces": ("list", roster.SETTING_ICE_INTERFACES),
- }
+ allowed_keys = NODE_SETTING_WRITERS
set_by = state.get("node_user_id", "")
updated = {}
@@ -1344,6 +1339,22 @@ async def set_node_settings(state: dict, settings: dict) -> dict:
setattr(nd, key, v)
await roster.set_node_setting(setting_key, str(v), set_by)
updated[key] = v
+ elif kind == "size":
+ # A quantity, not a count: half a gigabyte is a legitimate ceiling
+ # on a small disk, so this one is not run through the `int` branch
+ # above, whose floor of 1 would round it to "refuse everything".
+ # bool before float, as config.py does it: `true` is not 1 GB.
+ if isinstance(value, bool):
+ raise OpError(f"{key} must be a number")
+ try:
+ fv = float(value)
+ except (TypeError, ValueError):
+ raise OpError(f"{key} must be a number")
+ if fv <= 0:
+ raise OpError(f"{key} must be greater than zero")
+ setattr(nd, key, fv)
+ await roster.set_node_setting(setting_key, repr(fv), set_by)
+ updated[key] = fv
elif kind == "bool":
v = bool(value)
setattr(nd, key, v)
@@ -1383,6 +1394,10 @@ async def set_node_settings(state: dict, settings: dict) -> dict:
"max_concurrent_downloads"),
max_concurrent_uploads=updated.get(
"max_concurrent_uploads"))
+ if "max_upload_gb" in updated:
+ webrtc = state.get("webrtc")
+ if webrtc is not None:
+ webrtc.set_capacity(max_upload_gb=updated["max_upload_gb"])
if "stun_servers" in updated:
webrtc = state.get("webrtc")
if webrtc and hasattr(webrtc, '_stun'):
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 064ab93..8c9b3ef 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -983,35 +983,60 @@ class Roster:
SETTING_MAX_STREAMS = "max_concurrent_streams"
SETTING_MAX_DOWNLOADS = "max_concurrent_downloads"
SETTING_MAX_UPLOADS = "max_concurrent_uploads"
+ SETTING_MAX_UPLOAD_GB = "max_upload_gb"
SETTING_TRANSCODE = "transcode_incompatible_video"
SETTING_STUN_SERVERS = "stun_servers"
SETTING_ICE_INTERFACES = "ice_interfaces"
+ # Every setting this resolver answers for, and how a stored string becomes
+ # a value. `node_settings` iterates these two and nothing else, and
+ # `config.node_settings_defaults` is built from the same names — so the
+ # defaults dict cannot quietly cover fewer settings than are resolved.
+ #
+ # It did. The daemon's dict was written by hand and omitted the two
+ # transfer pools, so on a node with no panel override they resolved to
+ # `defaults.get(key)` → None, were assigned back onto the config, and the
+ # transport skipped them: `node.toml` was parsed, validated, and then
+ # replaced by the transport's own defaults. A missing key is not an error
+ # anywhere along that path — it is a setting that stops working in silence,
+ # and invisible unless the operator picked a value other than the default.
+ NODE_SETTING_SCALARS: tuple[tuple[str, str, str], ...] = (
+ ("invite_ttl_hours", SETTING_INVITE_TTL, "int"),
+ ("pair_ttl_hours", SETTING_PAIR_TTL, "int"),
+ ("device_request_ttl_minutes", SETTING_DEVICE_TTL, "int"),
+ ("max_concurrent_streams", SETTING_MAX_STREAMS, "int"),
+ ("max_concurrent_downloads", SETTING_MAX_DOWNLOADS, "int"),
+ ("max_concurrent_uploads", SETTING_MAX_UPLOADS, "int"),
+ ("transcode_incompatible_video", SETTING_TRANSCODE, "bool"),
+ ("max_upload_gb", SETTING_MAX_UPLOAD_GB, "float"),
+ )
+ NODE_SETTING_LISTS: tuple[tuple[str, str], ...] = (
+ ("stun_servers", SETTING_STUN_SERVERS),
+ ("ice_interfaces", SETTING_ICE_INTERFACES),
+ )
+
+ @classmethod
+ def node_setting_keys(cls) -> frozenset[str]:
+ """Every key `node_settings` returns — what a defaults dict must cover."""
+ return frozenset([k for k, _, _ in cls.NODE_SETTING_SCALARS]
+ + [k for k, _ in cls.NODE_SETTING_LISTS])
+
async def node_settings(self, defaults: dict) -> dict:
"""Current effective settings: roster override if present, else config default."""
import json as _json
result = {}
- for key, setting in [
- ("invite_ttl_hours", self.SETTING_INVITE_TTL),
- ("pair_ttl_hours", self.SETTING_PAIR_TTL),
- ("device_request_ttl_minutes", self.SETTING_DEVICE_TTL),
- ("max_concurrent_streams", self.SETTING_MAX_STREAMS),
- ("max_concurrent_downloads", self.SETTING_MAX_DOWNLOADS),
- ("max_concurrent_uploads", self.SETTING_MAX_UPLOADS),
- ("transcode_incompatible_video", self.SETTING_TRANSCODE),
- ]:
+ for key, setting, kind in self.NODE_SETTING_SCALARS:
stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting)
if stored is not None:
- if key == "transcode_incompatible_video":
+ if kind == "bool":
result[key] = stored != "0"
+ elif kind == "float":
+ result[key] = float(stored)
else:
result[key] = int(stored)
else:
result[key] = defaults.get(key)
- for list_key, setting in [
- ("stun_servers", self.SETTING_STUN_SERVERS),
- ("ice_interfaces", self.SETTING_ICE_INTERFACES),
- ]:
+ for list_key, setting in self.NODE_SETTING_LISTS:
stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting)
if stored is not None:
try:
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 ba8a3e4..8a5bbff 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -256,7 +256,13 @@ _CHAT_RATE_MAX_TRACKED = 1000
# a name the client chose, overwriting whatever was already there — which both violated
# node sovereignty and defeated the delete authorization (overwrite a file, become its
# recorded uploader, then delete it legitimately).
-MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB per file
+# The ceiling is the operator's to set (`max_upload_gb` in node.toml, the Node
+# page and `meshbay-node transfers max-size`) because it is their disk that
+# fills: this is only the default a node starts from when they have said
+# nothing. It is read from the transport context on every chunk, so a change
+# applies to an upload already in flight.
+MAX_UPLOAD_BYTES = 8 * 1024 * 1024 * 1024 # 8 GB per file
+GB_BYTES = 1024 * 1024 * 1024
# What the `tr` on a chunk request turned out to be (see `_lease_of`).
LEASE_GRANTED = "granted"
@@ -3552,7 +3558,21 @@ class WebRTCPeerSession:
"queued": len(progress.queued),
}
- # ── Transfer slots ───────────────────────────────────────────────────────
+ # ── Upload ceiling and transfer slots ────────────────────────────────────
+
+ def _max_upload_bytes(self) -> int:
+ """The per-file upload ceiling this node is running with, in bytes.
+
+ Read from the transport context rather than captured once, for the same
+ reason the transfer pools are refreshed there: the operator can change
+ it from the Node page or the CLI while an upload is running, and a
+ ceiling that only applies after a restart is not the one they were
+ shown. `None` means they have said nothing and the default stands.
+ """
+ gb = self._ctx.get("max_upload_gb")
+ if not gb:
+ return MAX_UPLOAD_BYTES
+ return max(1, int(float(gb) * GB_BYTES))
def _slots(self) -> "TransferSlots":
"""The node's transfer pools, shared across every peer and every group.
@@ -5597,7 +5617,7 @@ class WebRTCPeerSession:
_refuse("Unexpected chunk index", "bad_chunk_index")
return
- if state.bytes + len(chunk_bytes) > MAX_UPLOAD_BYTES:
+ if state.bytes + len(chunk_bytes) > self._max_upload_bytes():
uploads.drop(user_id, rel_dir, filename)
await off_disk(roots, tmp_path.unlink, True)
_refuse("Upload exceeds size limit", "too_large")
@@ -6991,6 +7011,7 @@ class WebRTCTransport:
max_concurrent_streams: int | None = None,
max_concurrent_downloads: int | None = None,
max_concurrent_uploads: int | None = None,
+ max_upload_gb: float | None = None,
transcode_incompatible_video: bool = True,
):
self._ctx: dict[str, Any] = {
@@ -7007,6 +7028,9 @@ class WebRTCTransport:
# the operator said nothing and transfers.py's defaults apply.
"max_concurrent_downloads": max_concurrent_downloads,
"max_concurrent_uploads": max_concurrent_uploads,
+ # The per-file upload ceiling, in GB. None means the operator said
+ # nothing and MAX_UPLOAD_BYTES stands.
+ "max_upload_gb": max_upload_gb,
# Operator opt-out (node.toml) for the HEVC-etc. transcode
# fallback in _stream_video_inner — real CPU cost, unlike copy.
"transcode_incompatible_video": transcode_incompatible_video,
@@ -7022,7 +7046,8 @@ class WebRTCTransport:
def set_capacity(self, *, max_concurrent_streams: int | None = None,
max_concurrent_downloads: int | None = None,
- max_concurrent_uploads: int | None = None) -> dict:
+ max_concurrent_uploads: int | None = None,
+ max_upload_gb: float | None = None) -> dict:
"""Resize a live pool without restarting the daemon.
`ops.set_node_settings` used to do this by assigning
@@ -7089,6 +7114,14 @@ class WebRTCTransport:
# prevent.
for lease in granted:
self._notify_granted(lease)
+
+ if max_upload_gb is not None:
+ gb = float(max_upload_gb)
+ if gb <= 0:
+ raise ValueError("max_upload_gb must be greater than zero")
+ self._ctx["max_upload_gb"] = gb
+ changed["max_upload_gb"] = gb
+ log.info("upload: per-file ceiling now %g GB", gb)
return changed
def _notify_granted(self, lease) -> None:
diff --git a/packages/meshbay-node/tests/test_chat_is_bounded.py b/packages/meshbay-node/tests/test_chat_is_bounded.py
index 1af72b4..4e767bc 100644
--- a/packages/meshbay-node/tests/test_chat_is_bounded.py
+++ b/packages/meshbay-node/tests/test_chat_is_bounded.py
@@ -13,7 +13,7 @@ it in `chat.db` on the operator's disk, where nothing expires it — retention i
a manual CLI command (§6.6) — relays it to every other connected member, and
has the hub write a notification for every member of the group. Uploads, the
other member-supplied write, have carried a filename allowlist, strict chunk
-ordering, a no-overwrite rule and a 4 GB cap since C5a. Chat carried nothing:
+ordering, a no-overwrite rule and a per-file size cap since C5a. Chat carried nothing:
the only ceiling was the DataChannel frame, 64 MB once the handshake is done.
One member in a loop filled the operator's disk and saturated everyone else's
connection, and the node's own answer to each message was `ack`.
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
index 9bf43d4..ad60b91 100644
--- a/packages/meshbay-node/tests/test_cli_dispatch.py
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -51,6 +51,11 @@ VERBS = [
["transfers", "set", "4", "2"],
["transfers", "set", "4"], # only one number: usage, then exit
["transfers", "set", "0", "2"], # zero is not "unlimited": refused
+ ["transfers", "max-size", "8"],
+ ["transfers", "max-size", "0.5"], # a fraction of a GB is legitimate
+ ["transfers", "max-size"], # no size: usage, then exit
+ ["transfers", "max-size", "0"], # zero is not "unlimited": refused
+ ["transfers", "max-size", "huge"], # not a number: refused
["transfers", "per-member", "4", "2"],
["transfers", "per-member", "4"], # only one number: usage, then exit
["transfers", "per-member", "0", "2"], # zero is refused here too
diff --git a/packages/meshbay-node/tests/test_node_settings_defaults.py b/packages/meshbay-node/tests/test_node_settings_defaults.py
new file mode 100644
index 0000000..f5d7856
--- /dev/null
+++ b/packages/meshbay-node/tests/test_node_settings_defaults.py
@@ -0,0 +1,85 @@
+"""
+Three lists name the same node settings, and they must agree.
+
+The resolver (`Roster.node_settings`) answers for a set of keys; the reader
+(`config.node_settings_defaults`) supplies what node.toml says for each; the
+writer (`ops.NODE_SETTING_WRITERS`) says which may be set and how each is
+validated. Nothing in the code path errors when the reader covers fewer keys
+than the resolver answers for: the missing one resolves to None, is written
+back onto the config, and the consumer falls through to its own default. So
+node.toml is parsed, validated — and ignored.
+
+That is what happened to `max_concurrent_downloads` and
+`max_concurrent_uploads`, and it was invisible because the value it fell back
+to was the same 8 the file suggests. Only an operator who set something else
+would ever have seen it, and then only as pools that did not match the file.
+"""
+import pytest
+from meshbay_node.config import NodeConfig, load_config, node_settings_defaults
+from meshbay_node.ops import NODE_SETTING_WRITERS
+from meshbay_node.roster import Roster
+
+
+def test_the_reader_covers_every_setting_the_resolver_answers_for():
+ assert set(node_settings_defaults()) == set(Roster.node_setting_keys())
+
+
+def test_the_writer_covers_every_setting_the_resolver_answers_for():
+ assert set(NODE_SETTING_WRITERS) == set(Roster.node_setting_keys())
+
+
+def test_no_default_is_none():
+ """A None here is indistinguishable from a key that is missing."""
+ missing = [k for k, v in node_settings_defaults().items() if v is None]
+ assert not missing
+
+
+@pytest.mark.asyncio
+async def test_node_toml_survives_startup_with_no_panel_override(tmp_path):
+ """The whole bug, at the level an operator meets it."""
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ '[hub]\nurl = "https://example.invalid"\nusername = "op"\n\n'
+ '[node]\n'
+ 'max_concurrent_downloads = 3\n'
+ 'max_concurrent_uploads = 2\n'
+ 'max_concurrent_streams = 5\n'
+ 'max_upload_gb = 4\n',
+ encoding="utf-8")
+ nd = load_config(conf).node
+
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ effective = await roster.node_settings(node_settings_defaults(nd))
+ finally:
+ await roster.close()
+
+ assert effective["max_concurrent_downloads"] == 3
+ assert effective["max_concurrent_uploads"] == 2
+ assert effective["max_concurrent_streams"] == 5
+ assert effective["max_upload_gb"] == 4.0
+
+
+@pytest.mark.asyncio
+async def test_a_panel_override_still_wins_over_the_file(tmp_path):
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ '[hub]\nurl = "https://example.invalid"\nusername = "op"\n\n'
+ '[node]\nmax_concurrent_downloads = 3\n', encoding="utf-8")
+ nd = load_config(conf).node
+
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ await roster.set_node_setting(Roster.SETTING_MAX_DOWNLOADS, "6", "op")
+ effective = await roster.node_settings(node_settings_defaults(nd))
+ finally:
+ await roster.close()
+
+ assert effective["max_concurrent_downloads"] == 6
+
+
+def test_an_absent_config_falls_back_to_the_dataclass_not_to_literals():
+ """`node_status` used to repeat the numbers by hand, and they went stale."""
+ assert node_settings_defaults(None) == node_settings_defaults(NodeConfig())
diff --git a/packages/meshbay-node/tests/test_transfer_settings.py b/packages/meshbay-node/tests/test_transfer_settings.py
index aa24782..bcfa6a8 100644
--- a/packages/meshbay-node/tests/test_transfer_settings.py
+++ b/packages/meshbay-node/tests/test_transfer_settings.py
@@ -139,15 +139,17 @@ async def test_the_node_wide_caps_round_trip_through_the_roster(roster):
**{k: None for k in ("invite_ttl_hours", "pair_ttl_hours",
"device_request_ttl_minutes",
"max_concurrent_streams",
- "transcode_incompatible_video")},
+ "transcode_incompatible_video",
+ "max_upload_gb")},
"stun_servers": [], "ice_interfaces": [],
}
await roster.set_node_setting(roster.SETTING_MAX_DOWNLOADS, "3", "op")
assert (await roster.node_settings(defaults))["max_concurrent_downloads"] == 3
-def test_node_toml_carries_both_keys():
+def test_node_toml_carries_the_upload_keys():
"""The template is what an operator reads before they read any document."""
from meshbay_node.config import EXAMPLE_CONFIG as tpl
assert "max_concurrent_downloads" in tpl
assert "max_concurrent_uploads" in tpl
+ assert "max_upload_gb" in tpl
diff --git a/packages/meshbay-node/tests/test_upload_size_cap.py b/packages/meshbay-node/tests/test_upload_size_cap.py
new file mode 100644
index 0000000..dca6e15
--- /dev/null
+++ b/packages/meshbay-node/tests/test_upload_size_cap.py
@@ -0,0 +1,152 @@
+"""
+How large a single upload may be, and who decides.
+
+The cap used to be a constant: 4 GB, in `webrtc_server.py`, the same on a Pi
+with a 32 GB card and on a machine holding a film library. It is the operator's
+disk that fills, so the number is theirs — `max_upload_gb` under [node] in
+node.toml, the Node page, and `meshbay-node transfers max-size`, with the
+constant as the default when they have said nothing.
+
+These follow the value along the whole path rather than checking that the field
+parses, for the reason `test_stream_capacity_config.py` gives: every join in
+such a path has been wrong at least once, and a ceiling read from the wrong
+place fails only when somebody sends a large file.
+"""
+
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from meshbay_node.config import load_config
+from meshbay_node.roots import RootSet
+from meshbay_node.transport.webrtc_server import (
+ GB_BYTES,
+ MAX_UPLOAD_BYTES,
+ WebRTCPeerSession,
+ WebRTCTransport,
+)
+
+
+def _cfg(tmp_path: Path, body: str):
+ p = tmp_path / "node.toml"
+ p.write_text(textwrap.dedent(body))
+ return load_config(p)
+
+
+class _FakePC:
+ def on(self, *a, **k):
+ return lambda f: f
+
+
+def _session(gb):
+ t = WebRTCTransport(
+ sk_node=None, hub_pk_pem=b"", gek=b"\0" * 32,
+ roots=RootSet(), index=None, max_upload_gb=gb)
+ return t, WebRTCPeerSession(_FakePC(), t._ctx, peer_id="p")
+
+
+# ── What the operator writes ─────────────────────────────────────────────────
+
+def test_the_default_is_eight_gb():
+ assert MAX_UPLOAD_BYTES == 8 * GB_BYTES
+
+
+def test_the_operator_sets_it(tmp_path):
+ cfg = _cfg(tmp_path, """
+ [node]
+ max_upload_gb = 20
+ """)
+ assert cfg.node.max_upload_gb == 20
+
+
+def test_a_fraction_of_a_gigabyte_is_legitimate(tmp_path):
+ """Not a count, so it does not get `_positive`'s floor of one.
+
+ A node on a small disk may well want to stop at half a gigabyte, and
+ rounding that to zero would refuse every upload.
+ """
+ cfg = _cfg(tmp_path, """
+ [node]
+ max_upload_gb = 0.5
+ """)
+ assert cfg.node.max_upload_gb == 0.5
+ _t, s = _session(0.5)
+ assert s._max_upload_bytes() == GB_BYTES // 2
+
+
+def test_saying_nothing_gets_the_default(tmp_path):
+ cfg = _cfg(tmp_path, """
+ [node]
+ quic_port = 19010
+ """)
+ assert cfg.node.max_upload_gb * GB_BYTES == MAX_UPLOAD_BYTES, (
+ "the config default and the source default disagree, so the ceiling "
+ "depends on whether a node.toml happens to mention it")
+
+
+@pytest.mark.parametrize("value", ["0", "-2", '"lots"', "true"])
+def test_a_value_that_would_refuse_every_upload_is_refused(tmp_path, value, caplog):
+ cfg = _cfg(tmp_path, f"""
+ [node]
+ max_upload_gb = {value}
+ """)
+ assert cfg.node.max_upload_gb * GB_BYTES == MAX_UPLOAD_BYTES
+ assert "max_upload_gb" in caplog.text, (
+ "the value was silently discarded — the operator has no way to learn "
+ "their setting is not in effect")
+
+
+# ── That the number reaches the check it governs ─────────────────────────────
+
+@pytest.mark.parametrize("gb,expect", [
+ (None, MAX_UPLOAD_BYTES),
+ (2, 2 * GB_BYTES),
+ (16, 16 * GB_BYTES),
+])
+def test_the_configured_size_is_what_the_handler_enforces(gb, expect):
+ _t, s = _session(gb)
+ assert s._max_upload_bytes() == expect
+
+
+def test_the_handler_reads_it_rather_than_the_constant():
+ """A ceiling captured once is one the operator cannot change.
+
+ The check runs per chunk, so raising the cap has to reach an upload that
+ is already in flight — which it does only if the handler asks the context
+ each time instead of closing over the constant.
+ """
+ src = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node"
+ / "transport" / "webrtc_server.py").read_text(encoding="utf-8")
+ i = src.index("Upload exceeds size limit")
+ check = src[src.rindex("if state.bytes", 0, i):i]
+ assert "_max_upload_bytes()" in check, (
+ "the upload check reads the module constant, so node.toml, the Node "
+ "page and the CLI are all read and then ignored")
+
+
+def test_raising_it_reaches_an_upload_already_running():
+ t, s = _session(2)
+ assert s._max_upload_bytes() == 2 * GB_BYTES
+ t.set_capacity(max_upload_gb=10)
+ assert s._max_upload_bytes() == 10 * GB_BYTES, (
+ "the session kept the ceiling it started with, so the setting only "
+ "takes effect on a restart"
+ )
+
+
+def test_zero_is_refused_at_the_transport_too():
+ t, _s = _session(4)
+ with pytest.raises(ValueError):
+ t.set_capacity(max_upload_gb=0)
+
+
+def test_the_daemon_passes_it():
+ """The join that syntax checking cannot see."""
+ daemon = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node"
+ / "daemon.py").read_text(encoding="utf-8")
+ i = daemon.index("WebRTCTransport(")
+ call = daemon[i:daemon.index(")", daemon.index("denylist=denylist", i))]
+ assert "max_upload_gb=self._config.node.max_upload_gb" in call, (
+ "the daemon builds the transport without the operator's ceiling, so "
+ "node.toml is read and then ignored")