diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-02 10:51:19 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-02 10:51:19 +0200 |
| commit | 754387590fa1754436b4648f969915888c6f6c9e (patch) | |
| tree | 53c833eeb4042e8ce5f96b882b23471daa9d9394 /packages/meshbay-node/src | |
| parent | c928547ca6e402bfe5e06bb59d55ac91e6822cd0 (diff) | |
| download | meshbay-28f88dc62af74ac5d7a78b083bcdc96bcb645de9.tar.gz | |
refactor(mnp): remove ten operator messages no client sent0.17
node_status, node_settings_set, roster_read, denylist_read, denylist_clear,
node_reload and the signed gek_rotate, member_unpin, transfer_limits,
group_detach leave MNP 6.0; the Node page and the CLI do this work over
loopback. Their ops keep their tests, moved to the ops level.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src')
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)))) |