From 1dcedc77083b908b7b3b431bad679a4813884355 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 10 Sep 2026 17:23:40 +0200 Subject: refactor(mnp)!: one answer to "may this member write", and it is the root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group-wide `member_upload` switch is gone: the message, the signed operation, the field on the handshake ack, the `upload` alias on every root in the index payload, and the client's fallback path to it. Whether a member may write has been a property of each root for a while, and that is the model that survives: a single flag over the group cannot express "this library is published read-only and that folder is a drop box", which is the ordinary arrangement. What was left of the switch was a handler that logged a deprecation and acted on nothing, and a client that read `ack.member_upload` whenever the roots carried no `writable` — a second source for one question, with whichever the code consulted first deciding it. `roots.describe()` drops `upload` for the same reason: it was `writable` under an older name, and two names for one boolean is one too many. The paperclip now says "nowhere to write" rather than picking a root, in a group that has none writable. That is the honest answer; the fallback picked whatever came first and failed at send time. Node suite 1215 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3 --- .../meshbay-common/src/meshbay_common/adminop.py | 7 +---- .../meshbay-common/src/meshbay_common/protocol.py | 2 -- .../src/meshbay_hub/static/files-app.js | 10 ++---- .../src/meshbay_hub/static/group-page.js | 21 ++----------- .../src/meshbay_hub/static/transport.js | 13 +------- .../meshbay-hub/tests/harness/group_tab_probe.py | 2 +- .../tests/harness/sticky_header_probe.py | 2 +- .../meshbay-hub/tests/test_mnp_1_0_node_compat.py | 31 ------------------- .../tests/test_upload_controls_hidden.py | 22 ++++++------- packages/meshbay-node/src/meshbay_node/roots.py | 4 +-- .../src/meshbay_node/transport/webrtc_server.py | 22 ------------- .../meshbay-node/tests/test_root_availability.py | 3 +- .../tests/test_root_writable_policy.py | 36 +++++++++++++--------- packages/meshbay-node/tests/test_roots.py | 17 +++++----- 14 files changed, 52 insertions(+), 140 deletions(-) (limited to 'packages') 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 ──────────────────────────────────────────────────────── -- cgit v1.2.3