diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-02 10:20:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-02 10:20:09 +0200 |
| commit | e4f61771131be635b9e81a19203a00707b4b19df (patch) | |
| tree | d80e4edafbeade3c27137e6753140e6585a26b9b /packages/meshbay-node/src | |
| parent | e941cc4c39c38a12220153ea572bd4c7bb92fde0 (diff) | |
| download | meshbay-e4f61771131be635b9e81a19203a00707b4b19df.tar.gz | |
feat(mnp): sharing a folder is decided on the node's machine only (MNP 6.0)
root_add, root_update and group_attach leave MNP: adding a directory and
switching writable/removable go through the loopback API (native dialog in
the desktop app) or the CLI. The operator's Settings tab still lists the
roots from any browser, read-only. The desktop app refuses to sign those
ops; a loopback flag change now reaches open pages (publish_roots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src')
5 files changed, 30 insertions, 174 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py index 4b619d7..f0505f7 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py @@ -719,6 +719,21 @@ class DirectoryIndexer: # The table now; the files when the scan ends. await self.on_change(self) + async def publish_roots(self) -> None: + """ + Tell every connected peer the table, after a root's flags were edited + in place (`ops.update_root`). + + A reload compares the edited set with itself and finds nothing to do, + so without this a directory made writable from the operator's own + machine stayed read-only on every open page until something else + happened to push the index. + """ + self._index.roots = self.roots.describe() + self._index.version = int(time.time()) + if self.on_change: + await self.on_change(self) + def _holds(self, root: Root) -> bool: return any(r.folded == root.folded and r.path == root.path for r in self.roots) diff --git a/packages/meshbay-node/src/meshbay_node/ops/roots.py b/packages/meshbay-node/src/meshbay_node/ops/roots.py index 480fe63..9dbade4 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/roots.py +++ b/packages/meshbay-node/src/meshbay_node/ops/roots.py @@ -174,11 +174,14 @@ async def update_root(state: dict, group_id: str, root_name: str, *, writable=match.writable, removable=match.removable) # Update the live RootSet so GET /api/groups returns correct data - # immediately, without waiting for the async reload to finish. + # immediately, without waiting for the async reload to finish — and the + # indexer's, which is normally the same object but need not be, since it + # is the one the table pushed to every peer is read from. live_roots: RootSet | None = state.get("groups_ctx", {}).get( group_id, {}).get("roots") - if live_roots: - for lr in live_roots.roots: + indexer = state.get("indexers", {}).get(group_id) + for rootset in {id(x): x for x in (live_roots, indexer and indexer.roots) if x}.values(): + for lr in rootset.roots: lr_name = lr.name or str(Path(lr.path).name) if fold(lr_name) == target: if writable is not None: @@ -187,6 +190,9 @@ async def update_root(state: dict, group_id: str, root_name: str, *, lr.removable = removable break + if indexer: + await indexer.publish_roots() + # Built from config when there is no live set, never returned empty: an # empty list is a *valid answer* meaning "this group has no directories", # and the client cannot tell it from "the node could not say". It would 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 daaee62..738dbce 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py @@ -18,7 +18,6 @@ from meshbay_common.adminop import ( OP_DIR_DELETE, OP_FILE_DELETE, OP_GEK_ROTATE, - OP_GROUP_ATTACH, OP_GROUP_DETACH, OP_INVITE_CANCEL, OP_INVITE_CREATE, @@ -26,11 +25,9 @@ from meshbay_common.adminop import ( OP_MEMBER_REVOKE, OP_MEMBER_UNPIN, OP_MUSICBRAINZ_ENABLED, - OP_ROOT_ADD, OP_ROOT_EJECT, OP_ROOT_PLUG, OP_ROOT_REMOVE, - OP_ROOT_UPDATE, OP_SEARCH_LISTED, OP_SET_SCAN_SETTINGS, OP_TMDB_CONFIG, @@ -72,17 +69,14 @@ _ADMIN_EXECUTORS = { OP_TMDB_OVERRIDE: "_admin_exec_tmdb_override", OP_TMDB_REMATCH: "_admin_exec_tmdb_rematch", OP_MUSICBRAINZ_ENABLED: "_admin_exec_musicbrainz_enabled", - OP_ROOT_ADD: "_admin_exec_root_add", OP_ROOT_REMOVE: "_admin_exec_root_remove", OP_APP_DIRECTORIES: "_admin_exec_app_directories", OP_CHAT_DIRECTORY: "_admin_exec_chat_directory", OP_CHAT_LINK_PREVIEW: "_admin_exec_chat_link_preview", OP_SEARCH_LISTED: "_admin_exec_search_listed", OP_CHAT_EPOCH: "_admin_exec_chat_epoch", - OP_ROOT_UPDATE: "_admin_exec_root_update", OP_ROOT_EJECT: "_admin_exec_root_eject", OP_ROOT_PLUG: "_admin_exec_root_plug", - OP_GROUP_ATTACH: "_admin_exec_group_attach", OP_GROUP_DETACH: "_admin_exec_group_detach", } 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 ee2e3a1..1ba5445 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py @@ -71,15 +71,12 @@ _HANDLERS = { MNP.MEMBER_UNPIN: ("_do_member_unpin", INLINE), MNP.GEK_ROTATE: ("_do_gek_rotate", INLINE), MNP.NODE_STATUS: ("_do_node_status", SPAWNED), - MNP.ROOT_ADD: ("_do_root_add", INLINE), MNP.ROOT_REMOVE: ("_do_root_remove", INLINE), - MNP.ROOT_UPDATE: ("_do_root_update", 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_ATTACH: ("_do_group_attach", INLINE), MNP.GROUP_DETACH: ("_do_group_detach", INLINE), MNP.NODE_SETTINGS_SET: ("_do_node_settings_set", SPAWNED), MNP.NODE_RELOAD: ("_do_node_reload", SPAWNED), 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 f7bbbfa..acaa33f 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 @@ -5,17 +5,12 @@ import logging from meshbay_common import MNP_VERSION from meshbay_common.adminop import ( - OP_GROUP_ATTACH, OP_GROUP_DETACH, - OP_ROOT_ADD, OP_ROOT_EJECT, OP_ROOT_PLUG, OP_ROOT_REMOVE, - OP_ROOT_UPDATE, OP_SET_SCAN_SETTINGS, OP_TRANSFER_LIMITS, - group_attach_subject, - root_add_subject, ) from meshbay_common.protocol import MNP @@ -235,52 +230,6 @@ class NodeOpsMixin: log.error("denylist_clear failed: %s", e, exc_info=True) self._send({"type": "error", "detail": "Internal error"}) - def _do_group_attach(self, msg: dict) -> None: - name = str(msg.get("name", "")).strip() - shared_dir = str(msg.get("shared_dir", "")).strip() - if not name or not shared_dir: - self._send({"type": "error", "detail": "Missing name or shared_dir"}) - return - if not self._has_admin_authority(): - self._send({"type": "error", "detail": "No authorized key for this"}) - return - # `upload_dir` is not read here any more, and a client still sending it - # is ignored rather than obeyed: on load it forces every other root - # read-only, which is the model the RO/RW one replaced. A second - # writable directory is `root_add` with `writable`. - writable = bool(msg.get("writable", True)) - # The directory being exposed is signed, not only the group's name. - self._issue_admin_challenge( - OP_GROUP_ATTACH, group_attach_subject(name, shared_dir, writable), - payload={"name": name, "shared_dir": shared_dir, "writable": writable}, - group_id="") - - async def _admin_exec_group_attach( - 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_attach:{pending['subject'][:16]}") - return - p = pending.get("payload") or {} - try: - result = await self._run_op( - ops.attach_group, p["name"], p["shared_dir"], - writable=bool(p.get("writable", True))) - except ops.OpError as e: - self._send({"type": "error", "detail": e.message}) - return - self._audit("group_attach", pending["subject"]) - self._send({"type": MNP.GROUP_ATTACH_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_attach failed: %s", e) - def _do_group_detach(self, msg: dict) -> None: name = str(msg.get("name", "")).strip() if not name: @@ -336,55 +285,6 @@ class NodeOpsMixin: log.error("node_reload failed: %s", e, exc_info=True) self._send({"type": "error", "detail": "Reload failed"}) - def _do_root_add(self, msg: dict) -> None: - target_group = str(msg.get("group_id", "")).strip() - path = str(msg.get("path", "")).strip() - if not target_group or not path: - self._send({"type": "error", "detail": "Missing group_id or path"}) - return - if not self._has_admin_authority(): - self._send({"type": "error", "detail": "No authorized key for this"}) - return - payload = { - "group_id": target_group, "path": path, - "name": str(msg.get("name", ""))[:128], - "kind": str(msg.get("kind", "generic"))[:16], - "writable": bool(msg.get("writable", msg.get("upload", False))), - "removable": bool(msg.get("removable", False)), - } - # Everything the executor acts on is signed — `writable` decides whether - # every member may write there. The group is in the transcript itself. - self._issue_admin_challenge( - OP_ROOT_ADD, - root_add_subject(path, payload["name"], payload["kind"], - payload["writable"], payload["removable"]), - payload=payload, group_id=target_group) - - async def _admin_exec_root_add( - 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"root_add:{pending['subject'][:24]}") - return - p = pending["payload"] - try: - result = await self._run_op( - ops.add_root, p["group_id"], p["path"], - name=p.get("name", ""), kind=p.get("kind", "generic"), - writable=p.get("writable", False), - removable=p.get("removable", False)) - except ops.OpError as e: - self._send({"type": "error", "detail": e.message}) - return - except Exception as e: - log.error("root_add failed: %s", e, exc_info=True) - self._send({"type": "error", "detail": "Internal error"}) - return - self._audit("root_add", f"{p['path']}→{p['group_id'][:8]}") - await self._retarget_indexer(p["group_id"]) - self._send({"type": MNP.ROOT_ADD_ACK, "v": MNP_VERSION, **result}) - def _do_root_remove(self, msg: dict) -> None: target_group = str(msg.get("group_id", "")).strip() root_name = str(msg.get("root_name", "")).strip() @@ -421,59 +321,6 @@ class NodeOpsMixin: await self._retarget_indexer(p["group_id"]) self._send({"type": MNP.ROOT_REMOVE_ACK, "v": MNP_VERSION, **result}) - def _do_root_update(self, msg: dict) -> None: - target_group = str(msg.get("group_id", self._group_id or "")).strip() - root_name = str(msg.get("root_name", "")).strip() - if not target_group or not root_name: - self._send({"type": "error", "detail": "Missing group_id or root_name"}) - return - if not self._has_admin_authority(): - self._send({"type": "error", "detail": "No authorized key for this"}) - return - updates = [] - if "writable" in msg: - updates.append(f"rw={'on' if msg['writable'] else 'off'}") - if "removable" in msg: - updates.append(f"rem={'on' if msg['removable'] else 'off'}") - subject = f"{root_name}:{','.join(updates)}" if updates else root_name - self._issue_admin_challenge( - OP_ROOT_UPDATE, subject, - payload={ - "group_id": target_group, "root_name": root_name, - "writable": msg.get("writable"), - "removable": msg.get("removable"), - }, - group_id=target_group) - - async def _admin_exec_root_update( - 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"root_update:{pending['subject'][:24]}") - return - p = pending["payload"] - try: - result = await self._run_op( - ops.update_root, p["group_id"], p["root_name"], - writable=p.get("writable"), removable=p.get("removable")) - except ops.OpError as e: - self._send({"type": "error", "detail": e.message}) - return - except Exception as e: - log.error("root_update failed: %s", e, exc_info=True) - self._send({"type": "error", "detail": "Internal error"}) - return - self._audit("root_update", pending["subject"]) - await self._retarget_indexer(p["group_id"]) - notice = {"type": MNP.ROOT_UPDATE_ACK, "v": MNP_VERSION, **result} - for uid, session in list(self._peer_registry().items()): - try: - session._send(notice) - except Exception: - pass - def _do_root_eject(self, msg: dict) -> None: target_group = str(msg.get("group_id", self._group_id or "")).strip() root_name = str(msg.get("root_name", "")).strip() @@ -558,24 +405,21 @@ class NodeOpsMixin: async def _retarget_indexer(self, group_id: str) -> None: """ - Pick up a root that was just added to or removed from node.toml. + Pick up a root that was just removed from node.toml. Through the daemon's own reload, which is what the loopback API has always done after the same operations (`ui/app.py`). This used to re-point the indexer at `groups_ctx[gid]["roots"]` instead — the very object the op had just edited — so `retarget` diffed a set against - itself, found no new names, scanned nothing, and dropped nothing. A - directory added over MNP reached node.toml and was invisible until a - restart; one removed kept serving its files. + itself, found no new names, scanned nothing, and dropped nothing: a + directory removed over MNP kept serving its files until a restart. Two front doors doing different things is the shape `ops.py` exists to prevent, and this was it: the loopback path worked and the MNP path did - not, which is why it survived until the operator added a directory from - a browser. + not. - Not awaited: a reload rescans, and a new library is minutes. The ack - the caller sends carries the set the node is moving to, and the - `index_sync` that follows the scan carries what it found. + Not awaited: a reload can be long. The ack the caller sends carries the + set the node is moving to. """ state = self._ctx.get("daemon_state") if not state: |