summaryrefslogtreecommitdiffstats
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/daemon.py99
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py16
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py47
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py19
-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/src/meshbay_node/ui/app.py9
7 files changed, 191 insertions, 93 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index f45101f..4fc07ad 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -36,6 +36,7 @@ from pathlib import Path
import uvicorn
+from meshbay_common.paths import fold
from meshbay_common import MNP_VERSION
from meshbay_common.protocol import MNP
from meshbay_node.audit import AuditStore
@@ -128,6 +129,11 @@ class _WsSender:
# ── Daemon ────────────────────────────────────────────────────────────────────
+def _root_shape(roots) -> set[tuple]:
+ """What has to match for a group's roots to count as unchanged on reload."""
+ return {(r.name, str(r.path), r.writable, r.removable) for r in roots}
+
+
class NodeDaemon:
def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH):
self._config = config
@@ -303,7 +309,7 @@ class NodeDaemon:
continue
try:
- roots = RootSet.build([asdict(r) for r in group_cfg.roots])
+ roots = await self._build_roots(group_cfg)
except RootError as e:
# Configuration the operator has to fix; guessing would put
# a member's file on the wrong disk or index one twice.
@@ -330,7 +336,7 @@ class NodeDaemon:
log.info("No GEK yet for group %s — will accept first setup",
group_cfg.name)
- # Read once at load, like member_upload/enabled_apps below —
+ # Read once at load, like enabled_apps below —
# kept current in place afterwards by set_scan_settings
# (ops.py), which updates both this indexer object directly
# and roster.db, so a restart picks up the same values.
@@ -347,6 +353,7 @@ class NodeDaemon:
sk_node=keys.sk_ed25519,
gek=gek,
on_change=self._on_index_change,
+ on_root_ejected=self._eject_persister(group_cfg.id),
cache=self._index_cache,
reconcile_secs=scan_settings["reconcile_interval_secs"],
debounce_secs=scan_settings["debounce_secs"],
@@ -374,22 +381,19 @@ class NodeDaemon:
"note_activity": indexer.note_activity,
# Shown to the operator in Settings, and kept current in
# place by set_scan_settings (ops.py) — same reasoning as
- # member_upload below.
+ # enabled_apps below.
"reconcile_interval_secs": scan_settings["reconcile_interval_secs"],
"debounce_secs": scan_settings["debounce_secs"],
"visibility": group_cfg.visibility,
# Admission policy comes from node.toml, never from the hub:
# a hub that could declare a group open would be handed its key.
"join_policy": group_cfg.join_policy,
- # Whether ordinary members may upload. Read once here, into
- # the context, because the upload handler is synchronous and
- # a database round trip per chunk would be absurd. The
- # signed operation that changes it updates this dict in
- # place, so the two never drift within a run.
- "member_upload": await self._roster.member_upload_allowed(
- group_cfg.id) if self._roster else True,
- # Same reasoning: read once at load, kept current in place
- # by the signed operation that changes it.
+ # Read once at load, kept current in place by the signed
+ # operation that changes it — the upload handler is
+ # synchronous and a database round trip per chunk would be
+ # absurd. (Whether a member may upload is not here any
+ # more: it is `writable` on the root being written to,
+ # which the RootSet above already carries.)
"enabled_apps": await self._roster.enabled_apps(
group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS),
# Which folder is the Videos app's entry point for this
@@ -745,14 +749,16 @@ class NodeDaemon:
if not ctx:
continue
try:
- roots = RootSet.build([asdict(r) for r in group_cfg.roots])
+ roots = await self._build_roots(group_cfg)
except RootError as e:
log.error("Group %r: %s — keeping the roots already loaded",
group_cfg.name, e)
continue
- before = {(r.name, str(r.path)) for r in ctx["roots"]}
- after = {(r.name, str(r.path)) for r in roots}
- if before == after:
+ # `writable` and `removable` are in the comparison because an
+ # operator editing node.toml by hand and reloading is a supported
+ # way to change them, and a set compared on name and path alone
+ # reports "nothing changed" for exactly that edit.
+ if _root_shape(ctx["roots"]) == _root_shape(roots):
continue
roots.refresh_availability()
indexer = next((i for i in self._indexers
@@ -786,7 +792,7 @@ class NodeDaemon:
continue
try:
- roots = RootSet.build([asdict(r) for r in group_cfg.roots])
+ roots = await self._build_roots(group_cfg)
except RootError as e:
log.error("New group %r: %s — skipping", group_cfg.name, e)
continue
@@ -812,6 +818,7 @@ class NodeDaemon:
sk_node=sk_ed,
gek=gek,
on_change=self._on_index_change,
+ on_root_ejected=self._eject_persister(group_cfg.id),
cache=self._index_cache,
reconcile_secs=scan_settings["reconcile_interval_secs"],
debounce_secs=scan_settings["debounce_secs"],
@@ -844,9 +851,6 @@ class NodeDaemon:
"debounce_secs": scan_settings["debounce_secs"],
"visibility": group_cfg.visibility,
"join_policy": group_cfg.join_policy,
- "member_upload": (
- await self._roster.member_upload_allowed(group_cfg.id)
- if self._roster else True),
"enabled_apps": (
await self._roster.enabled_apps(group_cfg.id)
if self._roster else list(Roster.DEFAULT_APPS)),
@@ -1052,6 +1056,35 @@ class NodeDaemon:
log.debug("Index progress pushed to %d peer(s) for group %s",
pushed, group_id[:8])
+ async def _build_roots(self, group_cfg) -> RootSet:
+ """
+ Build a group's RootSet from node.toml, with the ejected state restored.
+
+ node.toml carries configuration (`writable`, `removable`); the roster
+ carries the runtime answer to "is this drive ejected right now". They
+ are merged here, in the one place every caller goes through, because a
+ root that quietly comes back available across a restart is exactly the
+ surprise unplug that eject exists to survive.
+ """
+ specs = [asdict(r) for r in group_cfg.roots]
+ if self._roster:
+ ejected = await self._roster.ejected_roots(group_cfg.id)
+ if ejected:
+ for spec in specs:
+ name = spec.get("name") or Path(spec.get("path", "")).name
+ if fold(name) in ejected:
+ spec["ejected"] = True
+ return RootSet.build(specs)
+
+ def _eject_persister(self, group_id: str):
+ """`on_root_ejected` bound to one group, for that group's indexer."""
+ async def persist(root_name: str, ejected: bool) -> None:
+ if self._roster:
+ await self._roster.set_root_ejected(
+ group_id, root_name, ejected,
+ set_by=self._state.get("node_user_id", ""))
+ return persist
+
async def _on_index_change(self, indexer: DirectoryIndexer) -> None:
"""
Called when a DirectoryIndexer detects file changes — once per
@@ -1716,10 +1749,10 @@ def main() -> None:
help="group id (optional if only one is configured)")
parser.add_argument("--writable", action="store_true", default=None,
dest="writable",
- help="mark root as read-write (root set/add)")
+ help="root accepts member uploads (root add/set)")
parser.add_argument("--no-writable", action="store_false",
dest="writable",
- help="mark root as read-only (root set)")
+ help="root is read-only (root add/set, group add)")
parser.add_argument("--removable", action="store_true", default=None,
dest="removable",
help="mark root as removable (root set/add)")
@@ -2481,20 +2514,22 @@ def main() -> None:
sys.exit(1)
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
- body = {"name": args.target, "shared_dir": args.dir}
+ # Writable unless the operator says otherwise: a brand-new group that
+ # cannot receive a single file until its owner finds a second command
+ # is not a working group. Every root added *later* is read-only by
+ # default, which is the opposite rule and the right one there.
+ writable = args.writable is not False
+ body = {"name": args.target, "shared_dir": args.dir,
+ "writable": writable}
if args.upload_dir:
- import warnings
- warnings.warn(
- "--upload-dir is deprecated; the main root is writable by "
- "default. Use 'meshbay-node root add' for additional roots.",
- DeprecationWarning, stacklevel=1)
print("WARNING: --upload-dir is deprecated. The shared directory is "
- "writable by default. Use 'meshbay-node root add' for "
- "additional roots.")
+ "read-write by default; use 'meshbay-node root add "
+ "<path> --writable' for a second one.")
body["upload_dir"] = args.upload_dir
out = _daemon_api(cfg, "/api/groups/attach", method="POST", body=body)
print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}")
- print(f" shared_dir {out['shared_dir']} (writable)")
+ print(f" shared_dir {out['shared_dir']}"
+ f" ({'read-write' if writable else 'read-only'})")
if out.get("upload_dir"):
print(f" upload_dir {out['upload_dir']}")
print()
@@ -2512,7 +2547,7 @@ def main() -> None:
group_id = _resolve_group(cfg, args.group)
if sub == "list":
- out = _daemon_api(cfg, f"/api/groups")
+ out = _daemon_api(cfg, "/api/groups")
group = next((g for g in out.get("groups", [])
if g["id"] == group_id), None)
if not group:
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index 2911278..f7ffdca 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -263,12 +263,17 @@ class DirectoryIndexer:
cache: IndexCache | None = None,
reconcile_secs: float = DEFAULT_RECONCILE_SECS,
debounce_secs: float = DEFAULT_DEBOUNCE_SECS,
+ on_root_ejected: Callable[[str, bool], Awaitable[None]] | None = None,
):
self.roots = roots
self.group_id = group_id
self.sk_node = sk_node
self.gek = gek
self.on_change = on_change
+ # Called with (root_name, ejected) whenever this indexer changes a
+ # root's ejected state by itself — the surprise-unplug safety net.
+ # The daemon writes it to the roster, so a restart does not undo it.
+ self.on_root_ejected = on_root_ejected
self.reconcile_secs = reconcile_secs
self.debounce_secs = debounce_secs
# Current backoff delay — starts at reconcile_secs, doubles on every
@@ -582,6 +587,17 @@ class DirectoryIndexer:
changed = self.roots.refresh_availability()
touched = False
+ # Drained before the loop below, because persisting the flag is what
+ # makes the safety net survive a restart — and a restart is exactly
+ # what an operator does after noticing a drive fell off.
+ while self.roots.auto_ejected:
+ name = self.roots.auto_ejected.pop(0)
+ if self.on_root_ejected:
+ try:
+ await self.on_root_ejected(name, True)
+ except Exception:
+ log.exception("Could not persist the auto-eject of root %r", name)
+
for root, available in changed:
if available:
log.info("Root %r is back — rescanning", root.name)
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index c3a3f9c..13a7250 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -390,7 +390,7 @@ async def list_groups(state: dict) -> dict:
async def attach_group(state: dict, name: str, shared_dir: str,
- upload_dir: str = "") -> dict:
+ upload_dir: str = "", writable: bool = True) -> dict:
"""
Write a new [[groups]] block into node.toml.
@@ -448,7 +448,7 @@ async def attach_group(state: dict, name: str, shared_dir: str,
# Forward slashes: a Windows path in a TOML basic string is a
# parse error (`\U`, `\a`, ... are escapes). pathlib reads `/`.
f' path = "{path.as_posix()}"\n'
- f' writable = true\n')
+ f' writable = {"true" if writable else "false"}\n')
try:
with conf_path.open("a", encoding="utf-8", newline="\n") as f:
f.write(block)
@@ -457,6 +457,7 @@ async def attach_group(state: dict, name: str, shared_dir: str,
result = {"group_id": group["id"], "name": group["name"],
"shared_dir": str(path), "config": str(conf_path),
+ "writable": writable,
"note": "restart the node to pick it up"}
return result
@@ -629,7 +630,7 @@ def _remove_roots_block(conf_path: Path, group_id: str,
conf_path.write_text("\n".join(new_lines), encoding="utf-8", newline="\n")
return
- raise OpError(f"Root path not found in config", status=404)
+ raise OpError("Root path not found in config", status=404)
async def add_root(state: dict, group_id: str, path: str, *,
@@ -670,9 +671,9 @@ async def add_root(state: dict, group_id: str, path: str, *,
if kind != "generic":
root_block += f'\n kind = "{added.kind}"'
if writable:
- root_block += f'\n writable = true'
+ root_block += '\n writable = true'
if removable:
- root_block += f'\n removable = true'
+ root_block += '\n removable = true'
_insert_roots_block(conf_path, group_id, root_block)
from meshbay_node.config import RootSpec
@@ -822,18 +823,17 @@ async def eject_root(state: dict, group_id: str, root_name: str) -> dict:
return {"status": "already_ejected", "name": root_name,
"group_id": group_id, "roots": roots.describe()}
- root.ejected = True
- root.available = False
-
- roster = _roster(state)
- if roster:
- await roster.set_setting(
- group_id, f"root_ejected:{fold(root_name)}", "1",
- set_by=state.get("node_user_id", ""))
-
+ # The indexer stops its watchdog and freezes the entries; it holds the same
+ # RootSet object, but the flags are set here too so a context whose indexer
+ # was replaced by a retarget cannot be left disagreeing with the roster.
indexer = state.get("indexers", {}).get(group_id)
if indexer:
indexer.eject_root(root_name)
+ root.ejected = True
+ root.available = False
+
+ await _roster(state).set_root_ejected(
+ group_id, root_name, True, set_by=state.get("node_user_id", ""))
log.info("Root ejected: %s from group %s", root_name, group_id[:8])
return {"status": "ejected", "name": root_name, "group_id": group_id,
@@ -869,18 +869,17 @@ async def plug_root(state: dict, group_id: str, root_name: str) -> dict:
f"Directory not found: {root.path}. Is the device connected?",
status=409)
- root.ejected = False
- root.available = True
-
- roster = _roster(state)
- if roster:
- await roster.set_setting(
- group_id, f"root_ejected:{fold(root_name)}", "0",
- set_by=state.get("node_user_id", ""))
+ # Persisted before the rescan, which can take minutes on a large library:
+ # a crash halfway through must leave the root plugged, not ejected with
+ # entries half rebuilt.
+ await _roster(state).set_root_ejected(
+ group_id, root_name, False, set_by=state.get("node_user_id", ""))
indexer = state.get("indexers", {}).get(group_id)
if indexer:
await indexer.plug_root(root_name)
+ root.ejected = False
+ root.available = root.is_live()
log.info("Root plugged: %s in group %s", root_name, group_id[:8])
return {"status": "plugged", "name": root_name, "group_id": group_id,
@@ -947,7 +946,7 @@ def _update_root_field(conf_path: Path, group_id: str,
conf_path.write_text("\n".join(lines), encoding="utf-8", newline="\n")
return
- raise OpError(f"Root path not found in config", status=404)
+ raise OpError("Root path not found in config", status=404)
# ── Files ────────────────────────────────────────────────────────────────────
@@ -1125,6 +1124,8 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict:
"""
roster = _roster(state)
ctx = _group_ctx(state, group_id)
+ # See the same guard in webrtc_server._do_apps_enabled: Files cannot be
+ # turned off, and both writers put it at the front so the two agree.
if "files" not in apps:
apps = ["files"] + list(apps)
await roster.set_enabled_apps(group_id, apps,
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index a83b729..9d3f7cb 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -165,6 +165,13 @@ class RootSet:
roots: list[Root] = field(default_factory=list)
+ # Roots this set ejected by itself — a removable device that went away
+ # without the operator clicking Eject. Drained by the indexer, which is
+ # the only caller holding a roster to write the state to. Without that
+ # the flag is lost on the next restart, and the surprise unplug looks
+ # like a deletion all over again on the pass after it.
+ auto_ejected: list[str] = field(default_factory=list)
+
# ── Construction ─────────────────────────────────────────────────────────
@classmethod
@@ -212,9 +219,14 @@ class RootSet:
# Backward compat: old configs use `upload` instead of `writable`
writable = bool(spec.get("writable", spec.get("upload", False)))
+ # `ejected` is runtime state, not configuration — it reaches here
+ # only from the roster, restored at startup so a drive ejected
+ # before a restart does not come back on its own.
root = Root(name=name, path=path, kind=kind,
writable=writable,
removable=bool(spec.get("removable", False)),
+ ejected=bool(spec.get("ejected", False)),
+ available=not bool(spec.get("ejected", False)),
direct=bool(spec.get("direct", False)))
_refuse_nesting(root, roots)
roots.append(root)
@@ -331,8 +343,13 @@ class RootSet:
changed.append((root, False))
continue
live = root.is_live()
- if not live and root.removable and not root.ejected:
+ if not live and root.removable:
root.ejected = True
+ # Recorded for the caller to persist. A flag that only lives
+ # in memory would be forgotten on the next restart, and the
+ # rescan that followed would read an empty mount point as an
+ # erased library — the exact outcome eject exists to prevent.
+ self.auto_ejected.append(root.name)
log.warning("Root %r auto-ejected (device disappeared): %s",
root.name, root.path)
if live != root.available:
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index e2f749f..5f38acd 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -31,6 +31,8 @@ from pathlib import Path
import aiosqlite
+from meshbay_common.paths import fold
+
log = logging.getLogger(__name__)
# Crockford base32 without I, L, O and U: no character pair a human can confuse
@@ -543,10 +545,40 @@ class Roster:
# ── Group settings ──────────────────────────────────────────────────────
- # Whether members who are not the operator may upload. Default is yes: a
- # group that nobody may add to is the unusual case, and an existing node
- # must not change behaviour because a table was added under it.
- SETTING_MEMBER_UPLOAD = "member_upload"
+ # Whether a root is ejected. Runtime state, one key per root, keyed by the
+ # *folded* name so it agrees with the case-insensitive comparison the rest
+ # of the root code makes. It lives here rather than in node.toml because it
+ # is not configuration — an operator's hand-written config file should not
+ # be rewritten because a USB drive was unplugged — and it has to survive a
+ # restart, or the rescan that follows reads an empty mount point as an
+ # erased library, which is the whole thing eject exists to prevent.
+ SETTING_ROOT_EJECTED_PREFIX = "root_ejected:"
+
+ @classmethod
+ def root_ejected_key(cls, root_name: str) -> str:
+ return cls.SETTING_ROOT_EJECTED_PREFIX + fold(root_name)
+
+ async def set_root_ejected(self, group_id: str, root_name: str,
+ ejected: bool, set_by: str = "") -> None:
+ await self.set_setting(group_id, self.root_ejected_key(root_name),
+ "1" if ejected else "0", set_by)
+
+ async def ejected_roots(self, group_id: str) -> set[str]:
+ """
+ The folded names of this group's ejected roots.
+
+ Matched in Python rather than with `LIKE 'root_ejected:%'`: `_` is a
+ single-character wildcard there, so that pattern also matches keys this
+ does not own. A group has a handful of settings rows, so reading them
+ all costs nothing and the prefix test is then exact.
+ """
+ prefix = self.SETTING_ROOT_EJECTED_PREFIX
+ async with self._db.execute(
+ "SELECT key, value FROM group_settings WHERE group_id = ?",
+ (group_id,)) as cur:
+ rows = await cur.fetchall()
+ return {r["key"][len(prefix):] for r in rows
+ if r["key"].startswith(prefix) and r["value"] == "1"}
async def get_setting(self, group_id: str, key: str,
default: str | None = None) -> str | None:
@@ -567,17 +599,6 @@ class Roster:
(group_id, key, value, set_by, _now()))
await self._db.commit()
- async def member_upload_allowed(self, group_id: str) -> bool:
- """Whether an ordinary member may upload to this group."""
- value = await self.get_setting(group_id, self.SETTING_MEMBER_UPLOAD, "1")
- return value != "0"
-
- async def set_member_upload(self, group_id: str, allowed: bool,
- set_by: str = "") -> bool:
- await self.set_setting(group_id, self.SETTING_MEMBER_UPLOAD,
- "1" if allowed else "0", set_by)
- return allowed
-
# Which group "applications" (Chat, Files, and whatever registers later in
# apps.js) are shown to members. Unset means every app that exists — an
# existing group's tabs must not disappear because a node was upgraded.
@@ -609,7 +630,7 @@ class Roster:
# user_id)` authorizing the operator node-wide (desktop-client-v1.md
# §6.3). Unset means "the shipped default token, TMDB's own default
# language" — the same "absent means the old behaviour" discipline
- # member_upload/enabled_apps already follow.
+ # enabled_apps already follows.
#
# Whether TMDB is used *at all*, though, is per-group (moved off the
# node-wide sentinel below, 2026-08-24): an operator running a real media
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 4d6dd34..b99affc 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -1787,7 +1787,7 @@ class WebRTCPeerSession:
"""
Turn a group "application" on or off for everyone, for this group.
- Signed like `member_upload`: this decides what a member sees, and an
+ Signed like the root ops: this decides what a member sees, and an
unsigned message would let any member turn a disabled one back on.
"""
apps = msg.get("apps")
@@ -1799,8 +1799,12 @@ class WebRTCPeerSession:
self._send({"type": "error",
"detail": f"Unknown app(s): {', '.join(sorted(unknown))}"})
return
+ # Files is not a toggle: MNP permits root exploration regardless of
+ # what this list says, so hiding the tab only ever misled. Added at the
+ # front, the same order ops.set_enabled_apps writes, so the landing-tab
+ # preference sees one list and not two.
if "files" not in apps:
- apps.append("files")
+ apps.insert(0, "files")
if not self._has_admin_authority():
self._send({"type": "error", "detail": "No authorized key for this"})
return
@@ -1884,7 +1888,7 @@ class WebRTCPeerSession:
self._audit("tmdb_config", pending["subject"])
# Node-wide setting: every connected peer in every group is told, not
- # just this group's peers (unlike apps_enabled/member_upload/the
+ # just this group's peers (unlike apps_enabled/the root ops/the
# per-group tmdb_enabled below).
notice = {
"type": MNP.TMDB_CONFIG_ACK, "v": MNP_VERSION,
@@ -3795,24 +3799,34 @@ class WebRTCPeerSession:
"filename": filename})
return
- # The client names the target root. If absent, pick the first writable
- # one (backward compat with old clients that don't send it).
- target_root_name = msg.get("root")
+ # The client names the root it is uploading into — it is browsing one,
+ # and with several writable roots any other choice is a guess. It names
+ # a root, never a path: the destination inside it is decided below and
+ # is not negotiable, which is what keeps C5a closed.
+ #
+ # An unknown name is refused rather than falling back to a writable
+ # root, because "the file went somewhere else" is discovered weeks
+ # later — the same reason the old single upload root was never guessed.
+ # A client that names nothing is an MNP 1.0 one, and there was exactly
+ # one destination in its world: the first writable root.
+ target_root_name = str(msg.get("root") or "").strip()
upload_root = None
if target_root_name:
- from meshbay_common.paths import fold
- target_folded = fold(target_root_name)
- for r in roots:
- if fold(r.name) == target_folded:
- upload_root = r
- break
+ upload_root = roots.by_name(target_root_name)
+ if upload_root is None:
+ self._send({"type": "error",
+ "detail": f"No directory named "
+ f"{target_root_name!r} in this group",
+ "code": "no_such_root",
+ "filename": filename})
+ return
else:
writable = roots.writable_roots
upload_root = writable[0] if writable else None
if upload_root is None:
self._send({"type": "error",
- "detail": "No writable directory found for uploads",
+ "detail": "No writable directory in this group",
"code": "no_writable_root",
"filename": filename})
return
@@ -3827,6 +3841,7 @@ class WebRTCPeerSession:
self._send({"type": "error",
"detail": f"Directory '{upload_root.name}' is "
f"currently unavailable",
+ "code": "root_unavailable",
"filename": filename})
return
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 3d24000..ef86ce3 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -176,6 +176,7 @@ def create_ui_app(state: dict) -> FastAPI:
(payload.get("name") or "").strip(),
(payload.get("shared_dir") or "").strip(),
upload_dir=(payload.get("upload_dir") or "").strip(),
+ writable=bool(payload.get("writable", True)),
))
reload_fn = state.get("reload_fn")
if reload_fn:
@@ -419,14 +420,6 @@ def create_ui_app(state: dict) -> FastAPI:
"current_dir": progress.current_dir,
}
- # ── Upload toggle (DEPRECATED — per-root writable replaces this) ────
-
- @app.put("/api/groups/{group_id}/member-upload")
- async def set_member_upload(group_id: str, payload: dict):
- log.warning("PUT member-upload is deprecated — use PATCH roots/{name} "
- "with writable instead")
- return {"deprecated": True, "message": "Use per-root writable flag"}
-
# ── Enabled apps (operator only, localhost) ────────────────────────────
#
# Same loopback shape as member-upload: the Create Group wizard sets this