aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/chat.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/settings.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py49
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py10
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py84
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py210
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py7
8 files changed, 16 insertions, 358 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops/chat.py b/packages/meshbay-node/src/meshbay_node/ops/chat.py
index 469e907..539284b 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/chat.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/chat.py
@@ -17,7 +17,7 @@ log = logging.getLogger("meshbay_node.ops")
#
# The key a group's chat archive is encrypted under. Generated here, by the
# node, and never by a member — the C5b rule is about key material arriving from
-# outside, and this is the same rule that lets `gek_rotate` be a signed
+# outside, and this is the same rule that lets a group key rotation be an
# instruction rather than a delivery.
#
# An *epoch* rather than a rotation, and the distinction is the whole design:
diff --git a/packages/meshbay-node/src/meshbay_node/ops/settings.py b/packages/meshbay-node/src/meshbay_node/ops/settings.py
index eade6ca..906cd03 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/settings.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/settings.py
@@ -182,9 +182,9 @@ async def set_transfer_limits(state: dict, group_id: str,
Same shape as every other operator setting: lives on the node (roster.db,
not the hub and not node.toml, for the reason change 5 gives — a hub that
- decided this would have authority over someone else's machine), signed
- (webrtc_server checks the caller's admin authority before this runs), and
- live, so the pools are updated in place rather than at the next restart.
+ decided this would have authority over someone else's machine), set on the
+ node's own machine (loopback API, CLI), and live, so the pools are updated
+ in place rather than at the next restart.
"""
roster = _roster(state)
ctx = _group_ctx(state, group_id)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
index 738dbce..9a6cbd1 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
@@ -17,13 +17,10 @@ from meshbay_common.adminop import (
OP_CHAT_LINK_PREVIEW,
OP_DIR_DELETE,
OP_FILE_DELETE,
- OP_GEK_ROTATE,
- OP_GROUP_DETACH,
OP_INVITE_CANCEL,
OP_INVITE_CREATE,
OP_INVITE_LINK_CREATE,
OP_MEMBER_REVOKE,
- OP_MEMBER_UNPIN,
OP_MUSICBRAINZ_ENABLED,
OP_ROOT_EJECT,
OP_ROOT_PLUG,
@@ -34,7 +31,6 @@ from meshbay_common.adminop import (
OP_TMDB_ENABLED,
OP_TMDB_OVERRIDE,
OP_TMDB_REMATCH,
- OP_TRANSFER_LIMITS,
admin_transcript,
)
from meshbay_common.crypto import pk_to_b64
@@ -59,10 +55,7 @@ _ADMIN_EXECUTORS = {
OP_INVITE_CREATE: "_admin_exec_invite_create",
OP_INVITE_LINK_CREATE: "_admin_exec_invite_link_create",
OP_INVITE_CANCEL: "_admin_exec_invite_cancel",
- OP_GEK_ROTATE: "_admin_exec_gek_rotate",
- OP_MEMBER_UNPIN: "_admin_exec_member_unpin",
OP_APPS_ENABLED: "_admin_exec_apps_enabled",
- OP_TRANSFER_LIMITS: "_admin_exec_transfer_limits",
OP_SET_SCAN_SETTINGS: "_admin_exec_set_scan_settings",
OP_TMDB_CONFIG: "_admin_exec_tmdb_config",
OP_TMDB_ENABLED: "_admin_exec_tmdb_enabled",
@@ -77,7 +70,6 @@ _ADMIN_EXECUTORS = {
OP_CHAT_EPOCH: "_admin_exec_chat_epoch",
OP_ROOT_EJECT: "_admin_exec_root_eject",
OP_ROOT_PLUG: "_admin_exec_root_plug",
- OP_GROUP_DETACH: "_admin_exec_group_detach",
}
@@ -174,49 +166,12 @@ class AdminMixin:
says "the hub says you are the owner", which is the one thing NS4 and
M3 rule out: a hub that can name the operator can install itself as
node administrator. It rides the handshake ack so a client knows whether
- to offer the Node page at all, and every operation is gated on
- `_operator_device()` below.
+ to offer operator controls at all; every operation is gated on a
+ signature (`_verify_admin_sig`).
"""
node_user_id = self._ctx.get("node_user_id")
return bool(node_user_id and self._user_id == node_user_id)
- async def _operator_device(self) -> bool:
- """
- Whether this connection may run the node's own controls.
-
- Two things, and the second is the one that cannot be forged:
-
- - the account is the one this node belongs to (`_is_node_admin`), which
- is what keeps node-wide controls with the machine's owner rather than
- with every paired operator of every group on it; and
- - **the device on this connection proved a key the node pinned as an
- operator**. `device_hello` is signed over a transcript naming this
- node, this group and this connection's nonce, and `operator_pks()` is
- rebuilt from the roster on each call, so an unpinned browser and a
- revoked one are both refused at once.
-
- The second clause is the fix for the door this used to leave open.
- `node_status`, `node_settings_set`, `roster_read`, `denylist_read`,
- `denylist_clear` and `node_reload` were gated on the account id alone —
- a value the hub chooses. An active hub that can also reach the group key
- (which §3.5 concedes it can in an open-join group) could therefore mint
- a token for the owner's account and read `node_status`, which lists
- every group on the node with the operator's **absolute paths**, or clear
- the denylist, which is the persisted revocation H4 exists to keep.
-
- It holds no user keys and cannot countersign anything, so it cannot
- produce a `device_hello` — which is the same property device linking
- rests on (§3.3), applied to the node's own surface.
- """
- if not self._is_node_admin():
- return False
- if not self._device_confirmed or not self._pinned_pk:
- return False
- roster = self._ctx.get("roster")
- if roster is None:
- return False
- return self._pinned_pk in await roster.operator_pks()
-
def _has_admin_authority(self) -> bool:
"""
Cheap synchronous pre-check: is there anyone who could authorize this?
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
index 493d7f0..dc43ae2 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
@@ -198,9 +198,9 @@ class ChatMixin:
There is no switch to turn chat encryption on: MNP 2.0 has no plaintext
chat to fall back to. What an operator may want to do deliberately is
- move the key on — the same instruction as `gek_rotate`, and signed for
- the same reason. The removals that matter (member revoke, member unpin,
- device revoke, `gek_rotate`) already open one by themselves.
+ move the key on, which is signed like the rest. The removals that matter
+ (member revoke, member unpin, device revoke, a group key rotation)
+ already open one by themselves.
"""
group_id = str(msg.get("group_id", "")).strip() or self._group_id
if not group_id:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
index 1ba5445..4bf9dbb 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
@@ -48,7 +48,6 @@ _HANDLERS = {
MNP.DEVICE_REVOKE: ("_do_device_revoke", SPAWNED),
MNP.DEVICE_HELLO: ("_do_device_hello", SPAWNED),
MNP.APPS_ENABLED: ("_do_apps_enabled", INLINE),
- MNP.TRANSFER_LIMITS: ("_do_transfer_limits", INLINE),
MNP.SET_SCAN_SETTINGS: ("_do_set_scan_settings", INLINE),
MNP.TMDB_CONFIG: ("_do_tmdb_config", INLINE),
MNP.TMDB_ENABLED: ("_do_tmdb_enabled", INLINE),
@@ -68,18 +67,9 @@ _HANDLERS = {
MNP.MUSIC_META_REQ: ("_do_music_meta_request", SPAWNED),
MNP.AUDIO_TRANSCODE_REQ: ("_do_audio_transcode_request", SPAWNED),
MNP.SUBTITLE_REQ: ("_do_subtitle_request", SPAWNED),
- MNP.MEMBER_UNPIN: ("_do_member_unpin", INLINE),
- MNP.GEK_ROTATE: ("_do_gek_rotate", INLINE),
- MNP.NODE_STATUS: ("_do_node_status", SPAWNED),
MNP.ROOT_REMOVE: ("_do_root_remove", INLINE),
MNP.ROOT_EJECT: ("_do_root_eject", INLINE),
MNP.ROOT_PLUG: ("_do_root_plug", INLINE),
- MNP.ROSTER_READ: ("_do_roster_read", SPAWNED),
- MNP.DENYLIST_READ: ("_do_denylist_read", SPAWNED),
- MNP.DENYLIST_CLEAR: ("_do_denylist_clear", SPAWNED),
- MNP.GROUP_DETACH: ("_do_group_detach", INLINE),
- MNP.NODE_SETTINGS_SET: ("_do_node_settings_set", SPAWNED),
- MNP.NODE_RELOAD: ("_do_node_reload", SPAWNED),
MNP.KEYPAIR_BUNDLE_STORE: ("_do_keypair_bundle_store", SPAWNED),
MNP.KEYPAIR_BUNDLE_DELETE: ("_do_keypair_bundle_delete", SPAWNED),
MNP.USER_BLOB_STORE: ("_do_user_blob_store", SPAWNED),
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py
index 3756eac..a94e2aa 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/group_ops.py
@@ -5,9 +5,7 @@ from meshbay_common import MNP_VERSION
from meshbay_common.adminop import (
OP_APP_DIRECTORIES,
OP_APPS_ENABLED,
- OP_GEK_ROTATE,
OP_MEMBER_REVOKE,
- OP_MEMBER_UNPIN,
OP_SEARCH_LISTED,
)
from meshbay_common.groupbox import PURPOSE_ROSTER, seal
@@ -41,88 +39,6 @@ class GroupOpsMixin:
return
self._issue_admin_challenge(OP_MEMBER_REVOKE, user_id)
- def _do_gek_rotate(self, msg: dict) -> None:
- """
- Ask for a new group key. Operator only, and signed.
-
- This is what actually removes a revoked member's access: revocation
- stops the node serving the *next* key, and they still hold the current
- one. The node generates the replacement itself — nothing arriving here
- contributes key material, which is what the C5b rule is about.
- """
- group_id = str(msg.get("group_id", "")).strip() or self._group_id
- if not group_id:
- self._send({"type": "error", "detail": "No group on this connection"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- self._issue_admin_challenge(OP_GEK_ROTATE, group_id, group_id=group_id)
-
- async def _admin_exec_gek_rotate(
- self, pending: dict, transcript: bytes, sig: bytes,
- ) -> None:
- if not await self._verify_admin_sig(transcript, sig):
- self._send({"type": "error", "detail": "Signature verification failed"})
- self._audit("admin_auth_failed", f"gek_rotate:{pending['subject'][:8]}")
- return
- try:
- result = await self._run_op(
- ops.set_gek, pending["subject"], rotate=True)
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- # The operator is rotating because somebody left, and the chat archive
- # key is not derived from the group key — so rotating that one does not
- # move this one. Doing both here is what makes "rotate after a removal"
- # mean the same thing for chat as it does for files.
- await self._new_chat_epoch(pending["subject"], "gek_rotate")
- self._audit("gek_rotate", pending["subject"])
- self._send({
- "type": MNP.GEK_ROTATE_ACK, "v": MNP_VERSION,
- "group_id": pending["subject"],
- "authorized_members": result.get("authorized_members", 0),
- # Said plainly, because rotating is the step people skip: content
- # already downloaded stays readable to whoever holds it.
- "note": "members re-receive the key on their next connect; content "
- "already downloaded is unaffected",
- })
-
- def _do_member_unpin(self, msg: dict) -> None:
- """Forget a pinned identity, so someone can pair again with a new key."""
- user_id = str(msg.get("user_id", "")).strip()
- if not user_id:
- self._send({"type": "error", "detail": "Missing user_id"})
- return
- if user_id == self._user_id:
- # Unpinning yourself over the connection your pin authorizes would
- # end that connection's authority mid-operation.
- self._send({"type": "error", "detail": "Cannot unpin yourself"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- self._issue_admin_challenge(OP_MEMBER_UNPIN, user_id)
-
- async def _admin_exec_member_unpin(
- self, pending: dict, transcript: bytes, sig: bytes,
- ) -> None:
- user_id = pending["subject"]
- if not await self._verify_admin_sig(transcript, sig):
- self._send({"type": "error", "detail": "Signature verification failed"})
- self._audit("admin_auth_failed", f"member_unpin:{user_id[:8]}")
- return
- try:
- # The new chat epochs and the closed sessions are the op's own
- # (`ops.members._after_removal`), for every door alike.
- await self._run_op(ops.unpin_member, user_id)
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- self._audit("member_unpin", user_id)
- self._send({"type": MNP.MEMBER_UNPIN_ACK, "v": MNP_VERSION,
- "user_id": user_id})
-
# Every "application" a group can show. Photos joins this set (and
# apps.js's registry, client-side) when it lands; nothing else about
# this handler changes. DEFAULT_APPS (roster.py) deliberately does not
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py
index acaa33f..237a359 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py
@@ -1,16 +1,16 @@
-"""The operator's controls over the node itself: status and settings, roster
-and denylist, roots, hosted groups, reload, scan pacing and transfer limits."""
+"""The operator's controls over a group's roots and scan pacing that MNP carries:
+removing, ejecting and plugging a root. What widens the sharing, and the node's
+own status, settings, roster and denylist, are the loopback API's and the CLI's
+(MNP 6.0)."""
import logging
from meshbay_common import MNP_VERSION
from meshbay_common.adminop import (
- OP_GROUP_DETACH,
OP_ROOT_EJECT,
OP_ROOT_PLUG,
OP_ROOT_REMOVE,
OP_SET_SCAN_SETTINGS,
- OP_TRANSFER_LIMITS,
)
from meshbay_common.protocol import MNP
@@ -55,64 +55,6 @@ class NodeOpsMixin:
self._issue_admin_challenge(
OP_SET_SCAN_SETTINGS, f"{reconcile:g},{debounce:g}")
- MIN_TRANSFER_LIMIT = 1
- MAX_TRANSFER_LIMIT = 32
-
- def _do_transfer_limits(self, msg: dict) -> None:
- """How many transfers one member may run at once in this group.
-
- Zero is not "unlimited" and is refused: a member who may not transfer at
- all is a member the operator revokes, and reading 0 as no-limit would
- make the most dangerous value the easiest to type by accident.
- """
- try:
- downloads = int(msg.get("downloads"))
- uploads = int(msg.get("uploads"))
- except (TypeError, ValueError):
- self._send({"type": "error", "detail": "Invalid transfer limits"})
- return
- for value in (downloads, uploads):
- if not (self.MIN_TRANSFER_LIMIT <= value <= self.MAX_TRANSFER_LIMIT):
- self._send({"type": "error",
- "detail": f"transfer limits must be between "
- f"{self.MIN_TRANSFER_LIMIT} and "
- f"{self.MAX_TRANSFER_LIMIT}"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- self._issue_admin_challenge(OP_TRANSFER_LIMITS,
- f"d={downloads},u={uploads}")
-
- async def _admin_exec_transfer_limits(
- self, pending: dict, transcript: bytes, sig: bytes,
- ) -> None:
- try:
- parts = dict(p.split("=") for p in pending["subject"].split(","))
- downloads, uploads = int(parts["d"]), int(parts["u"])
- except (ValueError, KeyError):
- self._send({"type": "error", "detail": "Invalid transfer limits"})
- return
- if not await self._verify_admin_sig(transcript, sig):
- self._send({"type": "error", "detail": "Signature verification failed"})
- self._audit("admin_auth_failed", f"transfer_limits:{pending['subject']}")
- return
- try:
- result = await self._run_op(
- ops.set_transfer_limits, self._group_id or "", downloads, uploads)
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- self._audit("transfer_limits", pending["subject"])
-
- notice = {"type": MNP.TRANSFER_LIMITS_ACK, "v": MNP_VERSION,
- "limits": result["limits"]}
- for session in list(self._peer_registry().values()):
- try:
- session._send(notice)
- except Exception:
- pass
-
async def _admin_exec_set_scan_settings(
self, pending: dict, transcript: bytes, sig: bytes,
) -> None:
@@ -141,150 +83,6 @@ class NodeOpsMixin:
except Exception:
pass
- # ── Node management (D5) ─────────────────────────────────────────────────
-
- async def _do_node_status(self, msg: dict) -> None:
- """All groups, roots, peers — the operator's overview.
-
- Including every root's absolute path, which is why this is gated on a
- proved operator device and not on an account the hub named.
- """
- node_uid = self._ctx.get("node_user_id")
- log.info("node_status: user=%s node_user=%s owner=%s device=%s",
- self._user_id, node_uid, self._is_node_admin(),
- "confirmed" if self._device_confirmed else "unidentified")
- if not await self._operator_device():
- self._send({"type": "error", "detail": "Not the node operator",
- "code": "not_operator"})
- return
- try:
- result = await self._run_op(ops.list_groups)
- self._send({"type": MNP.NODE_STATUS_ACK, "v": MNP_VERSION, **result})
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- except Exception as e:
- log.error("node_status failed: %s", e, exc_info=True)
- self._send({"type": "error", "detail": "Internal error"})
-
- async def _do_node_settings_set(self, msg: dict) -> None:
- if not await self._operator_device():
- self._send({"type": "error", "detail": "Not the node operator",
- "code": "not_operator"})
- return
- settings = msg.get("settings", {})
- if not settings:
- self._send({"type": "error", "detail": "No settings provided"})
- return
- try:
- result = await self._run_op(ops.set_node_settings, settings)
- self._send({"type": MNP.NODE_SETTINGS_SET_ACK, "v": MNP_VERSION,
- **result})
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- except Exception as e:
- log.error("node_settings_set failed: %s", e, exc_info=True)
- self._send({"type": "error", "detail": "Internal error"})
-
- async def _do_roster_read(self, msg: dict) -> None:
- if not await self._operator_device():
- self._send({"type": "error", "detail": "Not the node operator",
- "code": "not_operator"})
- return
- group_id = str(msg.get("group_id", "")).strip()
- try:
- result = await self._run_op(ops.read_roster, group_id)
- self._send({"type": MNP.ROSTER_READ_ACK, "v": MNP_VERSION, **result})
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- except Exception as e:
- log.error("roster_read failed: %s", e, exc_info=True)
- self._send({"type": "error", "detail": "Internal error"})
-
- async def _do_denylist_read(self, msg: dict) -> None:
- if not await self._operator_device():
- self._send({"type": "error", "detail": "Not the node operator",
- "code": "not_operator"})
- return
- try:
- result = await self._run_op(ops.read_denylist)
- self._send({"type": MNP.DENYLIST_READ_ACK, "v": MNP_VERSION, **result})
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- except Exception as e:
- log.error("denylist_read failed: %s", e, exc_info=True)
- self._send({"type": "error", "detail": "Internal error"})
-
- async def _do_denylist_clear(self, msg: dict) -> None:
- if not await self._operator_device():
- self._send({"type": "error", "detail": "Not the node operator",
- "code": "not_operator"})
- return
- subject = str(msg.get("subject", "")).strip()
- try:
- result = await self._run_op(ops.clear_denylist, subject=subject)
- self._audit("denylist_clear", subject or "all")
- self._send({"type": MNP.DENYLIST_CLEAR_ACK, "v": MNP_VERSION, **result})
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- except Exception as e:
- log.error("denylist_clear failed: %s", e, exc_info=True)
- self._send({"type": "error", "detail": "Internal error"})
-
- def _do_group_detach(self, msg: dict) -> None:
- name = str(msg.get("name", "")).strip()
- if not name:
- self._send({"type": "error", "detail": "Missing group name or id"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- self._issue_admin_challenge(
- OP_GROUP_DETACH, name,
- payload={"name": name},
- group_id="")
-
- async def _admin_exec_group_detach(
- self, pending: dict, transcript: bytes, sig: bytes,
- ) -> None:
- if not await self._verify_admin_sig(transcript, sig):
- self._send({"type": "error", "detail": "Signature verification failed"})
- self._audit("admin_auth_failed",
- f"group_detach:{pending['subject'][:16]}")
- return
- p = pending.get("payload") or {}
- try:
- result = await self._run_op(ops.detach_group, p["name"])
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- self._audit("group_detach", pending["subject"])
- self._send({"type": MNP.GROUP_DETACH_ACK, "v": MNP_VERSION, **result})
- state = self._ctx.get("daemon_state")
- reload_fn = state.get("reload_fn") if state else None
- if reload_fn:
- try:
- await reload_fn()
- except Exception as e:
- log.error("Reload after group_detach failed: %s", e)
-
- async def _do_node_reload(self, msg: dict) -> None:
- if not await self._operator_device():
- self._send({"type": "error", "detail": "Not the node operator",
- "code": "not_operator"})
- return
- state = self._ctx.get("daemon_state")
- reload_fn = state.get("reload_fn") if state else None
- if not reload_fn:
- self._send({"type": "error", "detail": "Reload not available"})
- return
- try:
- await reload_fn()
- self._send({"type": MNP.NODE_RELOAD_ACK, "v": MNP_VERSION,
- "status": "reloaded"})
- except Exception as e:
- log.error("node_reload failed: %s", e, exc_info=True)
- self._send({"type": "error", "detail": "Reload failed"})
-
def _do_root_remove(self, msg: dict) -> None:
target_group = str(msg.get("group_id", "")).strip()
root_name = str(msg.get("root_name", "")).strip()
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index f810903..db11a09 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -535,10 +535,9 @@ def create_ui_app(state: dict) -> FastAPI:
@app.put("/api/groups/{group_id}/transfer-limits")
async def set_transfer_limits(group_id: str, payload: dict):
- # The same `ops.set_transfer_limits` the signed MNP handler calls. The
- # op existed with only that one door, and nothing anywhere opened it —
- # so the per-member cap sat at its default of 2 with no way to change
- # it, which from outside is indistinguishable from a hardcoded 2.
+ # The only door to `ops.set_transfer_limits` (the CLI uses it). It once
+ # had only a signed MNP message, which nothing anywhere sent — so the
+ # per-member cap sat at its default of 2 with no way to change it.
return await _op(lambda: ops.set_transfer_limits(
state, group_id,
int(payload.get("downloads", 0)), int(payload.get("uploads", 0))))