summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py7
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js13
-rw-r--r--packages/meshbay-hub/tests/harness/group_tab_probe.py2
-rwxr-xr-xpackages/meshbay-hub/tests/harness/sticky_header_probe.py2
-rw-r--r--packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py31
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py22
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py4
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py22
-rw-r--r--packages/meshbay-node/tests/test_root_availability.py3
-rw-r--r--packages/meshbay-node/tests/test_root_writable_policy.py36
-rw-r--r--packages/meshbay-node/tests/test_roots.py17
14 files changed, 52 insertions, 140 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 5c7345b..a80ffa9 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -45,10 +45,6 @@ OP_MEMBER_REVOKE = "member_revoke"
OP_GEK_ROTATE = "gek_rotate"
# Forgetting a pinned identity, so someone can pair again after losing a device.
OP_MEMBER_UNPIN = "member_unpin"
-# Turning uploading by ordinary members on or off. Signed like the rest: the
-# setting decides who may write to the operator's disk, so a node that took it
-# from an unsigned message would let any member re-enable it for everyone.
-OP_MEMBER_UPLOAD = "member_upload"
# Which group "applications" (Chat, Files, and whatever registers later) are
# shown to members. Signed like the rest: it decides what a member sees, not
# anything about key material, but an unsigned toggle would let any member
@@ -64,8 +60,7 @@ OP_SET_SCAN_SETTINGS = "set_scan_settings"
# How many transfers one member may run at once in this group. Signed like the
# rest: an unsigned cap is one any member can raise for themselves, which makes
# the control a suggestion. The subject is "d=2,u=2" so what the operator is
-# shown before signing names the outcome and not the operation -- the same rule
-# member_upload's on/off subject follows.
+# shown before signing names the outcome and not the operation.
OP_TRANSFER_LIMITS = "transfer_limits"
# Whether the node uses the operator's own API token/language instead of the
# shipped default — node-wide (docs/mediacenter.md §5.5), one credential
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index 5bd2903..5bd1206 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -148,8 +148,6 @@ class MNP:
MEMBER_REVOKE_ACK = "member_revoke_ack"
MEMBER_UNPIN = "member_unpin" # operator → node: forget an identity
MEMBER_UNPIN_ACK = "member_unpin_ack"
- MEMBER_UPLOAD = "member_upload" # operator → node: may members upload?
- MEMBER_UPLOAD_ACK = "member_upload_ack"
APPS_ENABLED = "apps_enabled" # operator → node: which group apps to show
APPS_ENABLED_ACK = "apps_enabled_ack"
TRANSFER_LIMITS = "transfer_limits" # operator → node: per-member caps for this group
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 882a8fa..638bc76 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -274,14 +274,8 @@ function FilesPanel({
: subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false);
const currentRootName = currentPath ? currentPath.split('/')[0] : '';
const currentRoot = currentRootName ? rootState.get(currentRootName) : null;
- // `upload` is the same answer under the name a node speaking MNP 1.0 uses;
- // reading only `writable` there means the Upload button disappears on every
- // node that has not been upgraded yet, which is most of them on the day the
- // page ships.
- const currentRootWritable = currentRoot
- ? (currentRoot.writable !== undefined ? currentRoot.writable
- : Boolean(currentRoot.upload))
- : false;
+ const currentRootWritable = currentRoot ? Boolean(currentRoot.writable)
+ : false;
// A member cannot create a folder at the top of a group: that level is the
// set of roots, which is the operator's configuration and not a directory on
// anyone's disk. The node refuses it, so offering it would only produce an
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 1b7ed53..0f442cf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -98,11 +98,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [nodeRoots, setNodeRoots] = useState([]);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
- // Legacy: the group-wide upload switch a node speaking MNP 1.0 sends on its
- // handshake ack. Per-root `writable` replaced it, and this is read only when
- // the roots carry no flags at all — see `attachRoot` below. Defaults to true
- // so such a node behaves as it always did.
- const [memberUpload, setMemberUpload] = useState(true);
// Which applications this group has enabled, from the node. Falls back to
// every registered app when a node predates the setting (or hasn't answered
// yet), so nothing disappears for an existing group.
@@ -350,7 +345,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
session.pendingJoinCode = null;
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
- setMemberUpload(ack.member_upload !== false);
setEnabledApps(ack.enabled_apps || null);
setScanSettings(ack.scan_settings || null);
setTmdbConfig({
@@ -377,10 +371,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setMusicbrainzConfig({
enabled: ack.musicbrainz_enabled !== false,
});
- // Changed while we are connected, by an operator who may be someone
- // else entirely. Without this the button stays until a reconnection,
- // and a button that is still there is a button people press.
- transport.onUploadPolicy = (allowed) => setMemberUpload(allowed);
transport.onAppsEnabled = (apps) => setEnabledApps(apps);
// Two independent acks now (tmdb_config_ack: token/language,
// node-wide; tmdb_enabled_ack: the per-group switch) — each merges
@@ -612,14 +602,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// the same rule the node applies when a client names no root at all. It
// becomes an operator-chosen directory in phase 2 (refactor-groups.md §1.7).
//
- // `memberUpload` is the fallback for a node still speaking MNP 1.0, whose
- // roots carry no `writable` at all: there, the single upload root is the one
- // the node marked, and the ack's computed flag is all we get.
const writableRoots = useMemo(
() => nodeRoots.filter((r) => r.writable && r.available !== false),
[nodeRoots]);
- const legacyNode = nodeRoots.length > 0
- && nodeRoots.every((r) => r.writable === undefined);
// The operator's chosen attachment folder wins where there is one — that is
// what the Chat settings pane is for. Its root has to be writable and
// present, or the choice is stale (they made it read-only, or ejected the
@@ -628,11 +613,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const chatDirUsable = Boolean(
chatDirRoot && writableRoots.some((r) => r.name === chatDirRoot));
const attachDir = chatDirUsable ? chatDirectory : '';
+ // Nowhere to write is a real answer: the paperclip says so rather than
+ // picking a read-only root and failing at send time.
const attachRoot = chatDirUsable ? chatDirRoot
: writableRoots.length ? writableRoots[0].name
- : (legacyNode && memberUpload
- ? (nodeRoots.find((r) => r.upload) || nodeRoots[0]).name
- : '');
+ : '';
// A single dispatcher so any app can open the right modal without owning
// video/preview state itself — Files' table and Chat's attachments both
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 03eb919..99e3fb9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -525,7 +525,6 @@ class MeshBayTransport {
set onStreamError(fn) { this._onStreamError = fn; }
set onIndexSync(fn) { this._onIndexSync = fn; }
set onIndexDelta(fn) { this._onIndexDelta = fn; }
- set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
set onRootsChanged(fn) { this._onRootsChanged = fn; }
/** The MNP version the connected node declared, or '' before a handshake. */
@@ -3241,17 +3240,7 @@ class MeshBayTransport {
return;
}
- // Legacy. An MNP 1.0 node still broadcasts this when its operator changes
- // the group-wide upload switch, and its roots carry no `writable` for us
- // to read instead — so this is the only answer available from such a node
- // and it is still honoured. Nothing here *sends* the message any more:
- // per-root RO/RW replaced it, and a current node answers it with a
- // deprecation notice and no action.
- if (msg.type === 'member_upload_ack' && this._onUploadPolicy) {
- this._onUploadPolicy(Boolean(msg.allowed));
- }
-
- // Same shape: the operator changed which apps are shown, and everyone
+ // The operator changed which apps are shown, and everyone
// connected hears about it without reconnecting.
if (msg.type === 'apps_enabled_ack' && this._onAppsEnabled) {
this._onAppsEnabled(msg.apps || []);
diff --git a/packages/meshbay-hub/tests/harness/group_tab_probe.py b/packages/meshbay-hub/tests/harness/group_tab_probe.py
index b6d2bcc..da1e8d1 100644
--- a/packages/meshbay-hub/tests/harness/group_tab_probe.py
+++ b/packages/meshbay-hub/tests/harness/group_tab_probe.py
@@ -52,7 +52,7 @@ window.MeshBayTransport = class {
constructor() { this.connected = false; this.memberRole = 'member'; }
async connect() {
this.connected = true;
- return { is_node_admin: false, member_upload: true,
+ return { is_node_admin: false,
enabled_apps: %(enabled)s,
tmdb_enabled: false, musicbrainz_enabled: false,
video_root: '', audio_root: '', photo_roots: [] };
diff --git a/packages/meshbay-hub/tests/harness/sticky_header_probe.py b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
index 9e9dc3c..320a2d4 100755
--- a/packages/meshbay-hub/tests/harness/sticky_header_probe.py
+++ b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
@@ -199,7 +199,7 @@ for (let d = 0; d < 40; d++)
path: `photos/sortie ${String(d).padStart(2, '0')}`, type: 'image' });
const ACK = {
- is_node_admin: true, member_upload: true,
+ is_node_admin: true,
enabled_apps: ['chat', 'files', 'video', 'music', 'photo'],
tmdb_enabled: false, musicbrainz_enabled: false,
video_directories: ['films'], music_directories: ['musique'],
diff --git a/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
index 63390af..b15018b 100644
--- a/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
+++ b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
@@ -11,8 +11,6 @@ sends **nothing back**, so a control that speaks MNP 1.1 to it produces a
thirty-second wait ending in a timeout, with nothing on screen to say the node
simply cannot do this. Three of them were like that before these tests:
-* Files' Upload button read `root.writable`, which a 1.0 node does not send —
- it says `upload`. The button disappeared on every un-upgraded node.
* The shared-directories toggles, eject and plug have no older equivalent at
all.
* The per-app folder pickers spoke `app_directories`, where a 1.0 node
@@ -71,22 +69,6 @@ def test_the_capability_reads_the_version_rather_than_guessing():
# ── The three degraded paths ────────────────────────────────────────────────
-def test_the_upload_button_reads_the_older_flag_too():
- """
- A 1.0 node's roots carry `upload`; `writable` is the same answer renamed.
- Reading only the new name hides the Upload button on every node that has
- not been updated, which on the day the page ships is all of them.
- """
- page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
- decl = page[page.index("const currentRootWritable"):]
- decl = decl[:decl.index(";") + 1]
- assert "currentRoot.upload" in decl, (
- "the Upload button ignores the flag an older node actually sends")
- assert "writable !== undefined" in decl, (
- "a root that is explicitly writable=false must stay read-only — "
- "falling through to `upload` there would reopen it")
-
-
def test_app_directories_fall_back_to_the_three_older_messages():
"""
Videos, Music and Photos each had their own message before the generic op,
@@ -160,16 +142,3 @@ def test_the_ack_is_read_in_both_shapes(app=None):
assert legacy in block, f"{legacy} is not read as a fallback"
assert "ack.chat_link_preview !== false" in page, (
"an absent link-preview switch must read as on, not off")
-
-
-def test_the_attachment_root_falls_back_to_the_older_answer():
- """
- A 1.0 node's roots carry no `writable`, so nothing looks writable and the
- paperclip would vanish. The group-wide `member_upload` flag is the only
- answer such a node gives, and it is what gets used.
- """
- page = GROUP_PAGE.read_text(encoding="utf-8")
- block = page[page.index("const writableRoots"):]
- block = block[:block.index("const commonProps")]
- assert "legacyNode" in block and "memberUpload" in block
- assert "writable === undefined" in block
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
index f6f476e..a2f9352 100644
--- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -180,19 +180,19 @@ def test_the_answer_comes_from_the_node(app):
assert "hubFetch" not in app[idx - 400:idx]
-def test_an_older_node_is_treated_as_permissive(app):
+def test_there_is_no_group_wide_upload_flag_to_read(app):
"""
- A node speaking MNP 1.0 sends roots with no `writable` at all, plus the old
- group-wide flag. Reading a missing field as "read-only" would close every
- group on the older half of the network.
+ Whether a member may write is a property of each root, and the page must
+ have no second source for it.
+
+ A group-wide flag beside the per-root answer is a page that can show an
+ Upload button the node will refuse, or hide one it would have allowed —
+ and whichever of the two the code happens to consult first decides.
"""
- assert "ack.member_upload !== false" in app
- assert "!== false" in app[app.index("ack.member_upload"):
- app.index("ack.member_upload") + 60]
- block = app[app.index("const legacyNode"):]
- block = block[:block.index("const commonProps")]
- assert "writable === undefined" in block, (
- "nothing distinguishes a 1.0 node from one with no writable roots")
+ assert "member_upload" not in app, (
+ "the page reads a group-wide upload flag again")
+ assert "r.writable" in app or "writable" in app, (
+ "the page has to read the per-root answer from somewhere")
def test_a_change_reaches_people_already_connected(app):
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index d288231..410fe68 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -371,9 +371,7 @@ class RootSet:
"available": r.available,
"writable": r.writable,
"removable": r.removable,
- "ejected": r.ejected,
- # Backward compat for MNP 1.0 clients
- "upload": r.writable}
+ "ejected": r.ejected}
# `with_paths` is for the operator's *own* channels only — the
# loopback API and the CLI reading it, both of which already
# require being on this machine with the run token. A member is
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 11412bf..ec7ef5d 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -67,7 +67,6 @@ from meshbay_common.adminop import (
OP_MEMBER_REVOKE,
OP_GEK_ROTATE,
OP_MEMBER_UNPIN,
- OP_MEMBER_UPLOAD,
OP_APPS_ENABLED,
OP_SET_SCAN_SETTINGS,
OP_TRANSFER_LIMITS,
@@ -570,8 +569,6 @@ class WebRTCPeerSession:
self._spawn(self._do_device_revoke(msg))
elif mtype == MNP.DEVICE_HELLO and self._nonce_node:
self._spawn(self._do_device_hello(msg))
- elif mtype == MNP.MEMBER_UPLOAD:
- self._do_member_upload(msg)
elif mtype == MNP.APPS_ENABLED:
self._do_apps_enabled(msg)
elif mtype == MNP.TRANSFER_LIMITS:
@@ -879,12 +876,6 @@ class WebRTCPeerSession:
# channel and nothing else.
config = {
"is_node_admin": self._is_node_admin(),
- # Backward compat for MNP 1.0 clients: computed from writable roots.
- # New clients read per-root writable from the index payload instead.
- "member_upload": any(
- r.get("writable") for r in
- (self._group_ctx().get("roots").describe()
- if self._group_ctx().get("roots") else [])),
# Which group "applications" to show. Absent/empty falls back to
# every registered one client-side, so a node that predates this
# setting (or one whose context has not loaded it yet) hides
@@ -2075,14 +2066,6 @@ class WebRTCPeerSession:
self._send({"type": MNP.MEMBER_UNPIN_ACK, "v": MNP_VERSION,
"user_id": user_id})
- def _do_member_upload(self, msg: dict) -> None:
- # Deprecated: upload control is now per-root via writable flag.
- # Old clients may still send this — acknowledge without acting.
- log.warning("Deprecated member_upload message received — use root "
- "writable/read-only instead")
- self._send({"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION,
- "allowed": True, "deprecated": True})
-
# 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
@@ -5321,11 +5304,6 @@ class WebRTCPeerSession:
elif pending["op"] == OP_MEMBER_UNPIN:
self._spawn(
self._admin_exec_member_unpin(pending, transcript, sig_bytes))
- elif pending["op"] == OP_MEMBER_UPLOAD:
- log.warning("Deprecated OP_MEMBER_UPLOAD signed op — use root "
- "writable/read-only instead")
- self._send({"type": MNP.MEMBER_UPLOAD_ACK, "v": MNP_VERSION,
- "allowed": True, "deprecated": True})
elif pending["op"] == OP_APPS_ENABLED:
self._spawn(
self._admin_exec_apps_enabled(pending, transcript, sig_bytes))
diff --git a/packages/meshbay-node/tests/test_root_availability.py b/packages/meshbay-node/tests/test_root_availability.py
index d514dee..2d848c7 100644
--- a/packages/meshbay-node/tests/test_root_availability.py
+++ b/packages/meshbay-node/tests/test_root_availability.py
@@ -121,8 +121,7 @@ async def test_members_are_told_which_roots_are_unavailable(tmp_path):
idx = await _indexer(_roots(films))
assert idx.index.roots == [
{"name": "Films", "kind": "generic", "available": True,
- "writable": True, "removable": False, "ejected": False,
- "upload": True}]
+ "writable": True, "removable": False, "ejected": False}]
(films / "a.mkv").unlink()
films.rmdir()
diff --git a/packages/meshbay-node/tests/test_root_writable_policy.py b/packages/meshbay-node/tests/test_root_writable_policy.py
index 8345880..730e636 100644
--- a/packages/meshbay-node/tests/test_root_writable_policy.py
+++ b/packages/meshbay-node/tests/test_root_writable_policy.py
@@ -1,10 +1,11 @@
"""
Who may write to the operator's disk, now that RO/RW on the root decides it.
-This replaces `test_member_upload_policy.py`. The old model had two orthogonal
-controls — one root designated as the upload target, and a group-wide
-`member_upload` switch — and collapsed into one property per root: `writable`.
-The properties worth keeping from the old file survive the change unaltered:
+One property per root — `writable` — and no second control anywhere: not a
+group-wide switch, not a designated upload target. Two controls for one question
+is a question answered differently depending on which is read first.
+
+The properties this holds:
* the interface hiding a control is a courtesy to the people who are not
trying; **the node refusing is the part that holds** against someone who is.
@@ -220,25 +221,32 @@ async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
assert [m for m in session.sent if m.get("type") == "error"]
-# ── The deprecated message must not still work ───────────────────────────────
+# ── There is no group-wide upload switch ─────────────────────────────────────
-async def test_the_old_member_upload_message_changes_nothing(tmp_path):
+async def test_no_message_can_reopen_uploads_for_a_whole_group(tmp_path):
"""
- MNP still parses `member_upload` so an old client gets an answer instead of
- a dropped request. What it must not do is act: this instruction could
- reopen uploads for a whole group, and a client old enough to send it is
- exactly one that knows nothing about read-only roots.
+ Whether a member may write is a property of each root, and there is no
+ second way to say it.
+
+ A group-wide switch is the thing this model replaced, and it cannot come
+ back by accident: the type does not exist, so a peer asking for it is a peer
+ the dispatcher logs and ignores. What must never happen is what a switch
+ would have allowed — one instruction turning a published, read-only library
+ into a writable one.
"""
+ assert not hasattr(MNP, "MEMBER_UPLOAD"), (
+ "a group-wide upload switch is back in the protocol")
+
session = _session(tmp_path, "member-1", writable=False)
session._has_admin_authority = lambda: True
issued = _capture_challenges(session)
- session._do_member_upload({"allowed": True})
+ session._dispatch_message({"type": "member_upload", "allowed": True})
- assert issued == [], "a deprecated instruction asked to be signed"
+ assert issued == [], "an unknown instruction asked to be signed"
assert session._ctx["roots"].roots[0].writable is False
- acks = [m for m in session.sent if m.get("type") == MNP.MEMBER_UPLOAD_ACK]
- assert acks and acks[0].get("deprecated") is True
+ assert not [m for m in session.sent if "upload" in str(m.get("type"))], (
+ "the node answered an instruction it does not implement")
# And the door is still shut.
_upload(session)
diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py
index 505091b..9233180 100644
--- a/packages/meshbay-node/tests/test_roots.py
+++ b/packages/meshbay-node/tests/test_roots.py
@@ -272,27 +272,26 @@ def test_describe_reports_what_a_member_needs(tmp_path):
described = roots.describe()
assert described == [
{"name": "Media", "kind": "generic", "available": True,
- "writable": True, "removable": False, "ejected": False,
- "upload": True},
+ "writable": True, "removable": False, "ejected": False},
{"name": "Music", "kind": "audio", "available": True,
- "writable": False, "removable": True, "ejected": False,
- "upload": False},
+ "writable": False, "removable": True, "ejected": False},
]
# Deliberately no paths: a member is told what exists and whether it is
# readable, not where on the operator's disk it lives.
assert not any("path" in d for d in described)
-def test_describe_still_carries_upload_for_mnp_1_0_clients(tmp_path):
+def test_describe_names_a_root_and_never_a_path(tmp_path):
"""
- `upload` is `writable` under its old name, kept because an MNP 1.0 client
- reads no other field and would otherwise decide the group takes no uploads
- at all. It is derived, never stored — the two can never disagree.
+ What a member is told about a root: that it exists, and whether it is
+ readable and writable. Never where on the operator's disk it lives — that
+ is the operator's own view (`with_paths`), over their own channel.
"""
(tmp_path / "Media").mkdir()
roots = RootSet.build([_spec(tmp_path / "Media", writable=True)])
described = roots.describe()[0]
- assert described["upload"] == described["writable"] is True
+ assert set(described) == {"name", "kind", "available", "writable",
+ "removable", "ejected"}
# ── SAFE_UPLOAD_NAME ────────────────────────────────────────────────────────