diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-06 17:48:36 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-06 17:48:36 +0200 |
| commit | ea56b8c79538323875c00db2e7006b255f7cd494 (patch) | |
| tree | ee08835bc190a75e49a6a8e78755111aef0e678f | |
| parent | e76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7 (diff) | |
| download | meshbay-ea56b8c79538323875c00db2e7006b255f7cd494.tar.gz | |
fix(groups): finish Phase 1 — MNP root management, upload targets, eject state
Review of the Phase 1 commit found the RO/RW model sound but three paths
unfinished, each of which broke the flow the phase exists to deliver. Plus
29 test failures it introduced and no coverage for anything it added.
Uploads went to the wrong directory. The node read a `root` field on
file_upload that no client ever sent, so every upload landed in the first
writable root while the Files toolbar offered its button based on the root
being browsed — with two writable roots, uploading from one wrote into the
other. Files now names the root it is showing; Chat names one chosen in the
shell (an operator-configured directory arrives in Phase 2); the node refuses
an unknown name rather than falling back, and refuses read-only and ejected
roots by code.
Shared directories were unreachable on the web. The table read its roots
only from the loopback API, which resolves to "not available" in a browser,
so the section rendered for nobody there — while the Uploads controls it
replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods
beside it were dead. MNP is now the path, loopback the fallback for a local
node with no live connection, and adding a root over MNP takes a typed path
since no web page can browse a remote disk.
Ejecting updated nobody's screen. transport.js resolves an admin ack against
the pending request and returns, which is right for every op whose caller
knows the value it chose; the root acks carry state only the node can compute,
so the operator who clicked Eject was the one client that never saw it happen.
And the ejected flag reached roster.db but was never read back, so a restart
undid it and the next scan read an empty mount point as an erased library.
Also: the member-upload endpoint answered 200 and did nothing (removed); the
wizard ignored the first root's RW switch; reload compared roots on name and
path, so editing writable in node.toml did nothing; the table had no path
column, which is the only thing separating two libraries sharing a basename;
apps_enabled normalisation differed between the two sides of a signed subject.
Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no
coverage at all. test_member_upload_policy.py is replaced by
test_root_writable_policy.py — it tested a removed feature — and every
property worth keeping from it moved rather than being dropped.
Docs: draft-v6 structural decision 9 is annotated as superseded (the operator
can no longer have a directory only they may write to — a real capability
removed, flagged rather than hidden), the man page documents the root verb and
the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong.
Suite: 41 failures before, 13 after — all 13 pre-existing on main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
43 files changed, 2058 insertions, 640 deletions
diff --git a/docs/apps.md b/docs/apps.md index ef8cc1a..7ce1e73 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -130,9 +130,10 @@ any app with similar per-group local state. ## 3. Enable/disable: the mechanism -Same shape as `member_upload` (`meshbay-draft-v6.md` §2.1b) — an +Same shape as a root's `writable` flag (`refactor-groups.md` §1.1) — an operator-signed setting, stored on the node, enforced by absence rather than -by the client's honesty. +by the client's honesty. It used to be described against `member_upload`, +which was the group-wide upload switch; that was removed in the same refactor. **Node side** (`meshbay_node/roster.py`): ```python @@ -148,8 +149,13 @@ from exactly one place: `webrtc_server.py`'s `_admin_exec_apps_enabled`, after `_do_apps_enabled` in `webrtc_server.py` validates before it ever issues a challenge: - `apps` non-empty — the operator can never lock a group down to nothing. -- every entry in `WebRTCPeerSession.ALLOWED_APPS` (`{"chat", "files"}` today) - — **this is the line a new app's node-side registration touches.** +- every entry in `WebRTCPeerSession.ALLOWED_APPS` + (`{"chat", "files", "video", "music", "photo"}` today) — **this is the line + a new app's node-side registration touches.** +- `files` is added to the list if it is absent, at both writers + (`_do_apps_enabled` and `ops.set_enabled_apps`, both at the front so the two + agree). It is not a toggle: MNP permits root exploration regardless of what + this list says, so hiding the tab only ever misled. The whole set is signed in one message (`apps_enabled`, `OP_APPS_ENABLED` in `meshbay_common.adminop`) rather than one op per app — ticking several boxes @@ -158,11 +164,11 @@ sorted, comma-joined app list (`"chat,files"`), built the same way on both sides so the operator's browser and the node arrive at identical bytes to sign/verify. -`enabled_apps` rides in `handshake_ack` and `node_status`, next to -`member_upload`. Changing it broadcasts `apps_enabled_ack` to everyone already -connected — `transport.js`'s `onAppsEnabled` — so a disabled tab disappears -without waiting for a reconnection, the same as `member_upload`'s live -broadcast. +`enabled_apps` rides in `handshake_ack` and `node_status`, next to the roots +table. Changing it broadcasts `apps_enabled_ack` to everyone already connected +— `transport.js`'s `onAppsEnabled` — so a disabled tab disappears without +waiting for a reconnection. The root ops (`root_update_ack`, `root_eject_ack`, +`root_plug_ack`) broadcast the same way, through `onRootsChanged`. **Client side:** `apps.js`'s `visibleApps(enabledKeys)` filters the registry; `group-page.js` calls it with `enabledApps` state (from the ack, `null` until @@ -234,9 +240,17 @@ the only node-side touches, and both are allow-lists, not new wire messages. machinery again; unlike Videos/Music it needs several root folders per group rather than one, has a single album-grid view with no third-party matching step, and reads EXIF locally on the node instead. -- **The offline/loopback settings path.** `member_upload` can be toggled two - ways: over a live MNP connection, or (Electron only) via the node's local - HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member- - upload')`, `group-settings.js`). `apps_enabled` only has the MNP path today. - Adding the loopback twin is a `meshbay_node.ui` endpoint plus a - `group-settings.js` branch, mirroring the existing `member_upload` one. +- **The offline/loopback settings path.** A root's flags can be changed two + ways: over a live MNP connection (any browser, anywhere), or — Electron + only, and only when MNP is not connected — via the node's local HTTP API + (`platform.node.call('PATCH', '/api/groups/<id>/roots/<name>')`, + `SharedDirectoriesTable` in `group-settings.js`). `apps_enabled` only has + the MNP path today. Adding the loopback twin is a `meshbay_node.ui` endpoint + plus a branch in the table's `run()` helper, mirroring the root ops. + + **MNP is the path that must exist, not the fallback.** The operator of a + node is not necessarily sitting at it. The first version of the shared + directories table read its roots exclusively from the loopback API, which + resolves to "not available" in a browser — so the whole section rendered for + nobody on the web, while the controls it replaced had worked there. Any + operator-facing setting added here needs the MNP route first. diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md index 180050c..4439cd1 100644 --- a/docs/meshbay-draft-v6.md +++ b/docs/meshbay-draft-v6.md @@ -57,7 +57,7 @@ | 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 | | 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 | | 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b | -| 9 | Node authority | The operator may **close uploading to everyone but themselves**, per group. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control | §2.1b | +| 9 | Node authority | The operator decides **which directories accept uploads**, per root. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control. **Superseded 2026-09-06** by `docs/refactor-groups.md` §1.1: the group-wide `member_upload` switch this section described is replaced by RO/RW per root, and the "everyone but the operator" carve-out is gone | §2.1b | | 10 | Client | A group's UI is a **set of pluggable applications** (Chat, Files today), not one monolithic page. Which are shown is a per-group, operator-signed setting on the same pattern as change 9 | §2.7 | | 11 | Hub role | The hub gains a **runtime instance-policy store** (`hub_settings`). First policy: an admin switches **public groups off** hub-wide, enforced server-side on every hub-mediated path. `suspend` vs `revoke` on a group are now written down as the distinct things they are | §2.8 | | 12 | Group registry | A group name is **unique per owner account**, not globally; the group's identity is still its UUID. Listed everywhere as `name@owner` | §2.9 | @@ -74,10 +74,12 @@ v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite, chunk ordering and a size cap. All four protections stand. Two amendments: -- There is no single `shared_root`. **The operator designates one root as the upload - destination**; the quarantine lives inside it. If that root is unavailable the upload - fails with a stated reason and never falls back to another; if none is designated, - uploads are refused rather than guessed. +- There is no single `shared_root`. **Each root is read-only or read-write**, and the + quarantine lives inside whichever writable root the upload is addressed to. If that + root is unavailable the upload fails with a stated reason and never falls back to + another; if the group has no writable root, uploads are refused rather than guessed. + (Amended 2026-09-06 — the original text designated *one* root as the upload + destination, and the client named none. See `docs/refactor-groups.md` §1.1.) - **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those @@ -91,6 +93,31 @@ device that asked, so the node keeps no thumbnail store. ### 2.1b §5.2 Uploads — the operator may close them +> **Superseded 2026-09-06.** `member_upload` is gone; the mechanism is `writable` on +> each root. What the three load-bearing properties below say is *unchanged* — read +> "the root's `writable` flag" for "`member_upload`" and every word of them still +> holds, which is why they are kept rather than deleted. What did change: +> +> - **It is per root, not per group.** A group can publish one library read-only and +> accept uploads into another, which the single switch could not express. +> - **There is no carve-out for the operator.** Read-only means read-only for +> everyone, because a published library that quietly accepts writes from whoever +> holds admin authority is not one. The paragraph below justifying the setting by +> "the only way to get a curated library was to designate no upload root at all, +> which refuses the operator too" is therefore the reasoning that was reversed: that +> *is* the model now, and refusing the operator is the point rather than the defect. +> - **The client names the destination root.** With several writable roots the node +> cannot choose without guessing, and a guess sends a member's file to a disk the +> operator did not intend. It names a root, never a path; everything below the root +> is still decided by the node. +> - The signed op is `OP_ROOT_UPDATE` (plus `OP_ROOT_EJECT` / `OP_ROOT_PLUG`) rather +> than `OP_MEMBER_UPLOAD`, and the flags live in `node.toml` — they are +> configuration — while the *ejected* runtime state lives in `roster.db`. +> `member_upload` survives on the handshake ack alone, computed as "any root is +> writable", for MNP 1.0 clients that read no other field. +> +> See `docs/refactor-groups.md` §1.1 and §1.5b. + New. A group where every member may add files is the default and stays the default; some groups want a library the operator curates, and until now the only way to get one was to designate no upload root at all, which refuses the operator too. diff --git a/docs/refactor-groups.md b/docs/refactor-groups.md index 11956ce..3d7412b 100644 --- a/docs/refactor-groups.md +++ b/docs/refactor-groups.md @@ -1,10 +1,14 @@ # Groups Refactor — Per-Root Permissions & App Plugin Architecture -> Status: **Phase 1 implemented.** Phase 2 and 3 not started. +> Status: **Phase 1 complete and reviewed** (2026-09-06). Phase 2 and 3 not started. > > This is the most significant refactoring of the project. It changes how roots > are permissioned, how group applications are configured, and how the Settings > and Create Group pages are structured. +> +> §7b records what the review of Phase 1 found and how the plan below was wrong +> where it was wrong. Read it before starting Phase 2 — two of its entries are +> rules the later phases have to follow, not one-off fixes. --- @@ -175,17 +179,31 @@ flip it back to available. refuses with "Directory not found. Is the device connected?" 4. On success: sets `ejected = false`, marks root `available = true`, restarts the watchdog observer -5. The indexer runs a **reconciliation** (not a full rescan) — compares frozen - entries against current filesystem state. New/changed/deleted files are - handled normally +5. The indexer **rescans the root** — its frozen entries are dropped and the + directory is read again. (The plan said "a reconciliation, not a full + rescan"; it is a rescan, deliberately. It is the same path a root coming + back from `refresh_availability` already took, and a device people carry + around can come back arbitrarily different — the hash cache means unchanged + files are not re-read, which is where the cost would have been.) 6. `index_sync` update propagates — entries reappear in all apps +**The flag is persisted, and restored at startup.** `ejected` lives in +`roster.db` (`root_ejected:<folded name>`), not in `node.toml`: it is runtime +state, and an operator's hand-written config must not be rewritten because a USB +drive was unplugged. It has to survive a restart — a restart is exactly what an +operator does after noticing a drive fell off, and a flag that only lived in +memory would let the scan that follows read the empty mount point as an erased +library. `daemon._build_roots()` merges the two sources; it is the only place +that builds a `RootSet` for a group. + **Auto-detection safety net.** If a `removable` root's path suddenly disappears (operator unplugged without clicking eject): - `refresh_availability()` detects `is_live() = false` - Because `removable = true`, it sets `ejected = true` automatically (as if the - operator had clicked eject) + operator had clicked eject), and reports it through the indexer's + `on_root_ejected` callback so the daemon writes it to `roster.db` — an + auto-eject that only existed in memory would be undone by the next restart - Entries freeze, no deletions propagate - The root stays in "ejected" state until the operator explicitly plugs it back @@ -281,25 +299,27 @@ For backward compatibility with MNP 1.0 peers: ### 1.10 CLI changes +As built. The group is a `--group` option rather than a positional, matching +every other verb in this CLI, and the negative flags are spelled `--no-writable` +/ `--no-removable` rather than `--read-only`, so each pair reads as one setting. + ``` # Group creation (first root defaults to RW) -meshbay-node group add <name> --dir <path> # first root, RW -meshbay-node group add <name> --dir <path> --read-only # first root, RO +meshbay-node group add <name> --dir <path> # first root, RW +meshbay-node group add <name> --dir <path> --no-writable # first root, RO -# Root management -meshbay-node root add <group> <path> [--name <name>] [--writable] [--removable] -meshbay-node root remove <group> <name> -meshbay-node root set <group> <name> --writable # toggle to RW -meshbay-node root set <group> <name> --read-only # toggle to RO -meshbay-node root set <group> <name> --removable # mark as removable -meshbay-node root set <group> <name> --no-removable # unmark -meshbay-node root eject <group> <name> # safe eject -meshbay-node root plug <group> <name> # re-plug -meshbay-node root list <group> +# Root management (--group is optional with one group configured) +meshbay-node root list [--group <name>] +meshbay-node root add <path> [--name <name>] [--writable] [--removable] +meshbay-node root remove <name> [--yes] +meshbay-node root set <name> --writable | --no-writable +meshbay-node root set <name> --removable | --no-removable +meshbay-node root eject <name> # safe eject +meshbay-node root plug <name> # re-plug -# Deprecated (removed with warning) +# Deprecated (accepted with a warning) --upload-dir → "use --writable on the target root instead" -member upload → "use 'root set --read-only' / 'root set --writable' instead" +member upload → removed; use 'root set --no-writable' / '--writable' ``` ### 1.11 HelloWorld proof-of-concept @@ -655,3 +675,80 @@ QE/migration/migrate_groups_v2.py (new, not | Phase 3 | HelloWorld + CLI polish + migration script | ~500 lines | Each phase is one focused Claude session. Test between phases. + +--- + +## 7b. Phase 1 review (2026-09-06) + +What the plan above got wrong, and what was actually built. The first three +entries are **rules for phases 2 and 3**, not one-off fixes: each describes a +shape the same code can take again. + +### The rules + +**An operator is not sitting at their node.** The shared directories table read +its roots exclusively from the loopback API (`platform.node.available`), which +resolves to "not available" in a browser. So the section rendered for nobody on +the web — while the Uploads controls it replaced *had* worked there — and the +`transport.updateRoot` / `ejectRoot` / `plugRoot` methods written next to it +were unreachable. §2.2 of the plan said "calls loopback API", and that was the +mistake: MNP is the path that must exist, and loopback is the fallback for a +local node with no live connection. Every operator-facing control phase 2 adds +(the folder tree, four app settings panes, the link-preview toggle) needs the +MNP route first. Pinned by `test_upload_controls_hidden.py`. + +**A reply that carries state nobody could have predicted has to be handed on.** +`transport.js` resolves an admin `*_ack` against the pending request and +returns, deliberately: every caller already updates local state from the value +it chose. The root acks are not like that — they carry the node's whole roots +table, including things only it knows (availability, the name it settled on, +the eject a failed plug left in place). Returning left the operator who clicked +Eject as the single client that never saw it happen, while every *other* peer +got the broadcast. Any phase-2 op returning computed state has the same shape. + +**A control that writes needs to name where.** The node was given a `root` +field on `file_upload` and no client ever sent it, so every upload went to +`writable_roots[0]` while the Files toolbar offered the button based on the root +being browsed. With two writable roots, uploading from one wrote into the other. +This is the failure `_settle_upload_root`'s deleted docstring existed to +prevent, reintroduced by removing it. Chat's attachments have the same problem +one level up and get an explicit `attachRoot` until §1.7 gives them a +configured directory. + +### The rest + +- **`ejected` was written to `roster.db` and never read back**, and the + auto-eject path did not persist at all. Both fixed; see §1.5b. +- **`PUT /api/groups/{gid}/member-upload` became a stub returning `200 + {"deprecated": true}`.** A route that answers OK and changes nothing is + indistinguishable from a working one to whoever calls it. Removed. +- **The wizard ignored the first root's RW switch** — `ops.attach_group` always + wrote `writable = true`. It takes the flag now. +- **`refresh_availability` was the only reader of a root's config.** The reload + path compared roots on `(name, path)`, so an operator editing `writable` in + `node.toml` and reloading saw nothing happen. The comparison includes the + flags. +- **The table had no Path column** (§1.5 asked for one). Two libraries whose + folders share a basename are indistinguishable without it, and the basename is + the identity — so it is the one thing that has to be visible. +- **Phase 1 shipped no tests.** 29 of the suite's failures were its own. The + gap that mattered was not the broken helpers but that eject, plug, per-root + upload refusal and the `node.toml` rewrite had no coverage at all: + `test_root_eject.py`, `test_root_writable_policy.py` and the new cases in + `test_ops.py` / `test_node_status.py` / `test_security_regressions.py` are + that. `test_member_upload_policy.py` is gone — it tested a removed feature. +- **`chat-app.js` was in §2.2's file list and was never touched.** +- **Eight of the ten locales were missing the new keys.** `test_locales.py` + holds them to `en.js`, so this was a failing test rather than a silent gap — + but it is worth noting that adding a key means adding it ten times. + +### Still open, deliberately + +- **The operator can no longer have a directory only they may write to.** RW is + open to every member; RO refuses everyone including the operator. This + reverses draft-v6's structural decision 9, which is annotated there. It is a + real capability removed, and if it turns out to be wanted the answer is a + third state on the root, not the old group-wide switch. +- `test_ops.py::test_a_backslash_path_written_into_node_toml_stays_parseable` + fails on any non-Windows machine and always has — it builds a + `PurePosixPath` from a Windows path. Unrelated to this refactor, left alone. diff --git a/man/meshbay-node.1 b/man/meshbay-node.1 index 1e1173b..92067a2 100644 --- a/man/meshbay-node.1 +++ b/man/meshbay-node.1 @@ -68,12 +68,18 @@ List all hosted groups with their roots, key status, file count, and connected peers. . .TP -\fBgroup add\fR \fIname\fR \fB\-\-dir\fR \fIpath\fR [\fB\-\-upload\-dir\fR \fIpath\fR] +\fBgroup add\fR \fIname\fR \fB\-\-dir\fR \fIpath\fR Attach a hub\-side group to this node by writing a .B [[groups]] entry to .IR node.toml . The group must already exist on the hub. +The directory becomes the group's first root, and is +.B read\-write +so that a new group can receive an upload without further configuration; +pass +.B \-\-no\-writable +for a group that only publishes. Run .B meshbay\-node reload afterwards, then @@ -88,6 +94,54 @@ Asks for confirmation unless .B \-\-yes is given. . +.SS Root management +A group has one or more named roots: directories on this machine that its +members see. Each is read\-only or read\-write, independently; a group whose +roots are all read\-only is valid and accepts no uploads at all. +. +.TP +.B root list +List this group's roots with their flags and current availability. +. +.TP +\fBroot add\fR \fIpath\fR [\fB\-\-name\fR \fIname\fR] [\fB\-\-writable\fR] [\fB\-\-removable\fR] +Add a directory to the group. The name defaults to the directory's +basename; two roots in a group cannot share a name, compared without +regard to case, and no root may sit inside another. +Run +.B meshbay\-node reload +afterwards to start indexing it. +. +.TP +\fBroot remove\fR \fIname\fR +Remove a root from the group. Files on disk are untouched; only +.I node.toml +changes. The last remaining root cannot be removed. +Asks for confirmation unless +.B \-\-yes +is given. +. +.TP +\fBroot set\fR \fIname\fR [\fB\-\-writable\fR|\fB\-\-no\-writable\fR] [\fB\-\-removable\fR|\fB\-\-no\-removable\fR] +Change a root's flags without removing it. Takes effect immediately; no +reload is needed. +. +.TP +\fBroot eject\fR \fIname\fR +Mark a removable root as ejected before physically disconnecting the +device. Its files are hidden from members and its index entries are +frozen \(em nothing is deleted \(em and the directory watcher stops, so +the unplug produces no deletions to propagate. The device can then be +removed safely. Refused on a root that is not marked +.BR removable . +. +.TP +\fBroot plug\fR \fIname\fR +Re\-enable an ejected root once the device is back. Refused if the +directory is not readable, since clearing the flag while the device is +still absent would hand the next scan an empty directory. The root is +rescanned, so anything that changed while it was away is picked up. +. .SS Member management .TP .B member list @@ -196,11 +250,33 @@ Shared directory, used with .BR "group add" . . .TP -\fB\-\-upload\-dir\fR \fIpath\fR -Separate upload directory, used with -.BR "group add" . -Files land directly in this directory (not in a subdirectory) and it -appears as its own root in the index. +.BR \-\-writable ", " \-\-no\-writable +Whether a root accepts uploads from group members, used with +.BR "root add" ", " "root set" " and " "group add" . +Uploads land in an +.I uploads +subdirectory of the root; existing files are never replaced. +A new root is read\-only unless +.B \-\-writable +is given; the directory passed to +.B "group add" +is the exception and is writable by default. +. +.TP +.BR \-\-removable ", " \-\-no\-removable +Whether a root lives on a device that gets disconnected, used with +.BR "root add" " and " "root set" . +Enables +.BR "root eject" " and " "root plug" , +and makes the node treat the directory suddenly disappearing as an +unannounced eject rather than as a deletion. +. +.TP +\fB\-\-name\fR \fIname\fR +Explicit name for a root, used with +.BR "root add" . +Default: the directory's basename. Required for a drive or filesystem +root, which has no basename to derive one from. . .TP .B \-\-yes @@ -343,15 +419,18 @@ Human\-readable group name. . .TP .B shared_dir -Single\-directory shorthand: equivalent to declaring one root named after -the directory's basename, which receives uploads. Cannot be combined with +Single\-directory shorthand: equivalent to declaring one read\-write root +named after the directory's basename. Cannot be combined with .BR [[groups.roots]] . . .TP .B upload_dir -A separate filesystem path for uploads. Files land directly in it (not in -a subdirectory) and it appears as its own root in the index. When set, -no other root receives uploads. +Deprecated. A separate filesystem path for uploads, from before roots +carried their own read\-write flag. A configuration still using it is +read as a second, writable root and every other root is forced +read\-only. Use +.B writable +on the intended root instead. . .TP .B visibility @@ -399,10 +478,30 @@ A view hint: one of Currently unused. . .TP -.B upload -Boolean. Exactly one root per group must receive uploads. Default: +.B writable +Boolean. Whether members may upload into this root. Uploads land in an +.I uploads +subdirectory; an existing file is never replaced. Any number of roots in +a group may be writable, including none. Default: +.BR false . +. +.TP +.B removable +Boolean. Whether this root lives on a device that gets disconnected. +Enables +.BR "meshbay\-node root eject" , +and makes the directory suddenly disappearing freeze the root rather +than look like a deletion of everything in it. Default: .BR false . . +.TP +.B upload +Deprecated spelling of +.BR writable , +read for configurations written before the two were separated. +.B writable +wins where both appear. +. .SS [keystore] .TP .B path diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 3bc110f..4714674 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -198,8 +198,13 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`; } +// `attachRoot` is the shared directory attachments are written to: the name of +// the first writable, available root, decided in group-page.js so Files and Chat +// read one answer. Empty means the group has no writable root right now — every +// root is read-only, or the one drive that was writable is unplugged — and the +// paperclip says so rather than producing a refusal from the node. function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, - onPreview, mayUpload = true, onActivity, status }) { + onPreview, attachRoot = '', onActivity, status }) { const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); @@ -479,7 +484,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, try { // Two people sending IMG_1234.jpg both succeed; the node picks a free name // and the message has to point at the one it chose. - const ack = await transport.uploadFile(file); + const ack = await transport.uploadFile(file, { root: attachRoot }); const storedAs = (ack && ack.stored_as) || file.name; await new Promise(r => setTimeout(r, 2500)); if (onRefreshIndex) await onRefreshIndex(); @@ -501,7 +506,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, } finally { setAttaching(false); } - }, [username, onRefreshIndex, jumpToBottom]); + }, [username, onRefreshIndex, jumpToBottom, attachRoot]); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -592,12 +597,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, </button> `} <div class="chat-input-row"> - ${mayUpload && html` + ${attachRoot ? html` <label class="chat-attach" title="${t('chat.attach')}"> ${attaching ? html`<span class="spinner"></span>` : html`<${Icon} name="clip" />`} <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} /> </label> + ` : html` + <span class="chat-attach chat-attach-off" title="${t('chat.attach_read_only')}"> + <${Icon} name="clip" /> + </span> `} <textarea class="chat-input" rows="1" ref=${inputRef} placeholder="${t('chat.placeholder')}" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js index a5676ba..c3b542f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js @@ -212,8 +212,16 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru // 2. Attach to node with first root update('running'); + // The first root is attached with the group, so its RW switch has to + // travel with it — writing it and then correcting it afterwards would + // leave a window where a group the operator marked read-only accepts + // uploads. const mainRoot = roots[0]; - const attachBody = { name: name.trim(), shared_dir: mainRoot.path }; + const attachBody = { + name: name.trim(), + shared_dir: mainRoot.path, + writable: mainRoot.writable !== false, + }; await platform.node.call('POST', '/api/groups/attach', attachBody); await platform.node.call('POST', '/api/reload'); update('done'); 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 56311ae..6825c8d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -26,7 +26,7 @@ import { function FilesPanel({ groupId, transportRef, gekRef, status, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, - isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, + isNodeAdmin, operatorPaired, userId, setError, onPreview, showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, }) { const [selected, setSelected] = useState(() => new Set()); @@ -75,6 +75,12 @@ function FilesPanel({ e.target.value = ''; const transport = transportRef.current; if (!files.length || !transport || !transport.connected) return; + // The root being browsed is the destination. A group can have several + // writable roots, so leaving the node to pick one means a file uploaded + // from a folder the operator is looking at lands in a different one — + // which is only noticed much later, if at all. + const uploadRoot = currentPath ? currentPath.split('/')[0] : ''; + if (!uploadRoot) return; setError(''); for (const file of files) { @@ -85,6 +91,7 @@ function FilesPanel({ // Bytes the node acknowledged, not bytes read locally. onProgress: (sent) => onProgress(sent, file.size), signal, + root: uploadRoot, }); // The node re-indexes on a filesystem event, so there is nothing to // wait on but the clock. Refreshing here means the file appears in @@ -94,7 +101,7 @@ function FilesPanel({ }, }); } - }, [applyIndex]); + }, [applyIndex, currentPath]); const makeDirectory = useCallback(async () => { const transport = transportRef.current; 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 1b2661c..dfde172 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -88,8 +88,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const [nodeRoots, setNodeRoots] = useState([]); const [isNodeAdmin, setIsNodeAdmin] = useState(false); - // DEPRECATED: memberUpload is now derived from per-root writable flags. - // Kept as state only for backward compat with nodes that still send it. + // 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 @@ -539,11 +541,26 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, } }, [groupId, token, descDraft, onGroupUpdated]); - // Asked in two places — the Files toolbar and the chat composer — so it is - // answered once. Per-root writable flags replace the old binary toggle; - // falls back to the legacy memberUpload for old nodes. - const hasWritableRoot = nodeRoots.some((r) => r.writable); - const mayUpload = hasWritableRoot || memberUpload || isNodeAdmin; + // Where an attachment goes, answered once for the whole page. + // + // Files does not use this — it uploads into the root being browsed, which is + // the only unambiguous answer once a group can have several writable roots. + // Chat has no folder to browse, so it needs one picked for it, and this is + // 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); + const attachRoot = 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 @@ -587,7 +604,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, groupId, transportRef, gekRef, status, username, entries, availableEntries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, - isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, + isNodeAdmin, operatorPaired, attachRoot, userId, setError, onPreview, onRefreshIndex: refreshIndex, onActivity: touchActivity, videoRoot, onVideoRoot: (path) => setVideoRoot(path), audioRoot, onAudioRoot: (path) => setAudioRoot(path), @@ -717,6 +734,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin} userId=${userId} operatorPaired=${operatorPaired} connected=${status === 'connected'} + mnpRoots=${nodeRoots} enabledApps=${enabledApps} onEnabledApps=${(keys) => setEnabledApps(keys)} scanSettings=${scanSettings} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index de6f8c0..eeed786 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -177,42 +177,104 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) { // ── Shared Directories Table ──────────────────────────────────────────── /** - * Reusable table of a group's root directories with per-root controls. + * A group's root directories, and the operator's controls over them. * - * Used in both the Settings page (with full edit controls) and the Create - * Group wizard (with add-only). Each root shows its name, a writable - * toggle, a removable badge, and eject/plug buttons for removable roots. + * One component, two modes, because the Create Group wizard and the Settings + * page were drifting apart while showing the same thing: + * + * mode="live" — a hosted group. Every change is a signed operator op sent + * over MNP, or the loopback API when the node is on this + * machine and there is no live connection. + * mode="local" — the wizard, before the group exists. Changes are held in + * an array the caller owns; nothing is persisted until the + * group is attached. + * + * **Both paths matter and neither is optional.** The operator of a node is not + * necessarily sitting at it: they may be signing in from any browser, and the + * only thing that reaches their node from there is MNP. An earlier version of + * this read its roots exclusively from the loopback API, which resolves to + * "not available" in a browser — so the section rendered for nobody on the + * web, while the controls it replaced had worked there. `mnpRoots` is the + * source whenever a connection exists; the loopback list is the fallback for + * a local node that is not currently connected (a group still scanning, say). * * Props: - * roots — array of { name, writable, removable, ejected, available, kind } - * groupId — the group id - * transport — MeshBayTransport instance (null when not connected) - * signFn — signing function for admin ops - * platform — platform bridge (for Electron root picker) - * nodeDetected — whether local node API is available - * readOnly — suppress edit controls (default false) - * onRootsChange — callback(roots) after a change - * onRefreshIndex — trigger a full index refresh after add/remove - */ -/** - * Two modes: - * mode="live" — connected to a node, persists changes via MNP/loopback API - * mode="local" — during group creation, manages a local array, reports changes - * via onLocalRootsChange(roots) + * roots — the node's current roots: { name, path, writable, + * removable, ejected, available, kind } + * groupId — the group id + * transport — MeshBayTransport instance, or null when not connected + * signFn — signing function for admin ops + * nodeDetected — whether the loopback node API answers + * readOnly — suppress every edit control + * onRootsChange — called after a change, to re-read the loopback list + * onRefreshIndex — full index refresh, needed after an add or a remove + * mode — "live" (default) or "local" + * localRoots / onLocalRootsChange — the array, in "local" mode */ function SharedDirectoriesTable({ roots, groupId, transport, signFn, - nodeDetected: nodeAvail, readOnly, - onRootsChange, onRefreshIndex, - mode = 'live', - localRoots, onLocalRootsChange }) { + nodeDetected: nodeAvail, readOnly, + onRootsChange, onRefreshIndex, + mode = 'live', + localRoots, onLocalRootsChange }) { const isLocal = mode === 'local'; - const [optimistic, setOptimistic] = useState({}); - const serverRoots = isLocal ? (localRoots || []) : roots; - const displayRoots = serverRoots.map(r => - optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r); + const serverRoots = isLocal ? (localRoots || []) : (roots || []); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); const [indexProgress, setIndexProgress] = useState(null); + const [pathDraft, setPathDraft] = useState(''); + const [addingByPath, setAddingByPath] = useState(false); + + // A toggle has to move under the finger, and the answer only comes back + // when the node has signed, written node.toml and pushed the new table. + // The patch is therefore held until the incoming `roots` actually agrees + // with it — clearing it when the request resolves (which is what this did) + // drops it in the frame *before* the new table arrives, so the switch + // visibly snaps back and then forward again. + const [optimistic, setOptimistic] = useState({}); + useEffect(() => { + setOptimistic((prev) => { + const keys = Object.keys(prev); + if (!keys.length) return prev; + const next = {}; + let changed = false; + for (const name of keys) { + const server = serverRoots.find(r => r.name === name); + const patch = prev[name]; + // Gone from the table, or the server now says what we asked for: + // either way this patch has nothing left to hide. + const settled = !server + || Object.keys(patch).every(k => server[k] === patch[k]); + if (settled) changed = true; else next[name] = patch; + } + return changed ? next : prev; + }); + }, [serverRoots]); + + const displayRoots = serverRoots.map(r => + optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r); + + // Which door a change goes through. MNP first: it is the only one that + // exists for an operator on the web, and it is signed, which the loopback + // API is not (it is authorized by being on localhost with the run token). + const overMnp = !isLocal && transport && transport.connected; + const overLoopback = !isLocal && !overMnp && nodeAvail; + const canEdit = !readOnly && (isLocal || overMnp || overLoopback); + + const rootUrl = (name, suffix = '') => + '/api/groups/' + groupId + '/roots/' + encodeURIComponent(name) + suffix; + + const run = useCallback(async (work, { refreshIndex = false } = {}) => { + setBusy(true); setMsg(''); + try { + await work(); + if (onRootsChange) await onRootsChange(); + if (refreshIndex && onRefreshIndex) await onRefreshIndex(); + return true; + } catch (err) { + setMsg(platform.bridgeMessage(err)); + return false; + } finally { setBusy(false); } + }, [onRootsChange, onRefreshIndex]); const doUpdateRoot = useCallback(async (rootName, updates) => { if (isLocal) { @@ -222,53 +284,35 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, } return; } - setOptimistic(prev => ({ ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates } })); - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.updateRoot(groupId, rootName, updates, signFn); - } else if (nodeAvail) { - await platform.node.call('PATCH', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName), - updates); - } - if (onRootsChange) await onRootsChange(); - } catch (err) { setMsg(err.message); } - finally { - setOptimistic(prev => { const next = { ...prev }; delete next[rootName]; return next; }); - setBusy(false); + setOptimistic(prev => ({ + ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates }, + })); + const ok = await run(async () => { + if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn); + else if (overLoopback) await platform.node.call('PATCH', rootUrl(rootName), updates); + else throw new Error(t('node.root_no_route')); + }); + // Only a failure clears the patch here; a success waits for the node's + // own table, so the switch never travels backwards on its way forwards. + if (!ok) { + setOptimistic(prev => { + const next = { ...prev }; delete next[rootName]; return next; + }); } - }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange]); + }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, + transport, groupId, signFn, run]); - const doEjectRoot = useCallback(async (rootName) => { - if (isLocal) return; - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.ejectRoot(groupId, rootName, signFn); - } else if (nodeAvail) { - await platform.node.call('PUT', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/eject'); - } - if (onRootsChange) onRootsChange(); - } catch (err) { setMsg(err.message); } - finally { setBusy(false); } - }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]); + const doEjectRoot = useCallback((rootName) => run(async () => { + if (overMnp) await transport.ejectRoot(groupId, rootName, signFn); + else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/eject')); + else throw new Error(t('node.root_no_route')); + }), [overMnp, overLoopback, transport, groupId, signFn, run]); - const doPlugRoot = useCallback(async (rootName) => { - if (isLocal) return; - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.plugRoot(groupId, rootName, signFn); - } else if (nodeAvail) { - await platform.node.call('PUT', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/plug'); - } - if (onRootsChange) onRootsChange(); - } catch (err) { setMsg(err.message); } - finally { setBusy(false); } - }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]); + const doPlugRoot = useCallback((rootName) => run(async () => { + if (overMnp) await transport.plugRoot(groupId, rootName, signFn); + else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/plug')); + else throw new Error(t('node.root_no_route')); + }), [overMnp, overLoopback, transport, groupId, signFn, run]); const doRemoveRoot = useCallback(async (rootName) => { if (isLocal) { @@ -278,59 +322,99 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, return; } if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return; - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.removeRoot(groupId, rootName, signFn); - } else if (nodeAvail) { - await platform.node.call('DELETE', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName)); + const ok = await run(async () => { + if (overMnp) await transport.removeRoot(groupId, rootName, signFn); + else if (overLoopback) { + await platform.node.call('DELETE', rootUrl(rootName)); await platform.node.call('POST', '/api/reload'); - } - setMsg(t('node.root_removed')); - if (onRootsChange) onRootsChange(); - if (onRefreshIndex) await onRefreshIndex(); - } catch (err) { setMsg(err.message); } - finally { setBusy(false); } - }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange, onRefreshIndex]); + } else throw new Error(t('node.root_no_route')); + }, { refreshIndex: true }); + if (ok) setMsg(t('node.root_removed')); + }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, + transport, groupId, signFn, run]); - const doAddRoot = useCallback(async () => { - const chosen = await platform.rootPicker.choose(); - if (!chosen) return; + // Adding a root needs a directory that exists on the *node's* filesystem. + // With the node on this machine that is a native folder picker; from any + // other browser the operator has to type the path, because nothing in a web + // page can browse a remote disk. Both end at the same signed op. + const addRootAtPath = useCallback(async (path, name) => { if (isLocal) { - if ((localRoots || []).some(r => r.path === chosen.path)) return; + if ((localRoots || []).some(r => r.path === path)) return true; const isFirst = (localRoots || []).length === 0; - const newRoot = { - name: chosen.name, path: chosen.path, - writable: isFirst, removable: false, - }; - if (onLocalRootsChange) onLocalRootsChange([...(localRoots || []), newRoot]); - return; + // The first directory is writable so a new group can receive an upload + // without the operator having to find this switch first. Every later + // one is read-only until they say otherwise. + if (onLocalRootsChange) { + onLocalRootsChange([...(localRoots || []), + { name, path, writable: isFirst, removable: false }]); + } + return true; } - setBusy(true); setMsg(''); setIndexProgress(null); - try { - if (nodeAvail) { - await platform.node.call('POST', - '/api/groups/' + groupId + '/roots', - { path: chosen.path, name: chosen.name }); + setIndexProgress(null); + return run(async () => { + if (overMnp) { + await transport.addRoot(groupId, path, { name }, signFn); + } else if (overLoopback) { + await platform.node.call('POST', '/api/groups/' + groupId + '/roots', + { path, name }); await platform.node.call('POST', '/api/reload'); await platform.watchIndexProgress(groupId, setIndexProgress); - } - setMsg(t('node.root_added')); - if (onRootsChange) onRootsChange(); - if (onRefreshIndex) await onRefreshIndex(); - } catch (err) { setMsg(platform.bridgeMessage(err)); } - finally { setBusy(false); } - }, [isLocal, localRoots, onLocalRootsChange, groupId, nodeAvail, onRootsChange, onRefreshIndex]); + } else throw new Error(t('node.root_no_route')); + }, { refreshIndex: true }); + }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, + transport, groupId, signFn, run]); + + const doPickRoot = useCallback(async () => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + const ok = await addRootAtPath(chosen.path, chosen.name); + if (ok && !isLocal) setMsg(t('node.root_added')); + }, [addRootAtPath, isLocal]); + + const doAddByPath = useCallback(async () => { + const path = pathDraft.trim(); + if (!path) return; + // The name is the node's business — it derives the basename and refuses a + // duplicate. Sending one guessed from a string typed here would be a + // second opinion about something already decided in one place. + const ok = await addRootAtPath(path, ''); + if (ok) { setPathDraft(''); setAddingByPath(false); if (!isLocal) setMsg(t('node.root_added')); } + }, [pathDraft, addRootAtPath, isLocal]); - if (!displayRoots || displayRoots.length === 0) { + const addControls = !canEdit ? '' : html` + ${platform.rootPicker.available ? html` + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${busy} onClick=${doPickRoot}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + ` : addingByPath ? html` + <div class="sdt-add-row"> + <input class="sdt-add-input" type="text" value=${pathDraft} + placeholder=${t('node.root_path_placeholder')} + disabled=${busy} + onInput=${(e) => setPathDraft(e.target.value)} + onKeyDown=${(e) => { if (e.key === 'Enter') doAddByPath(); }} /> + <button class="btn btn-small btn-secondary" disabled=${busy || !pathDraft.trim()} + onClick=${doAddByPath}>${t('node.add_root')}</button> + <button class="btn btn-small" disabled=${busy} + onClick=${() => { setAddingByPath(false); setPathDraft(''); }}> + ${t('settings.cancel')}</button> + </div> + <p class="settings-hint">${t('node.root_path_hint')}</p> + ` : html` + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${busy} onClick=${() => setAddingByPath(true)}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + `} + `; + + if (!displayRoots.length) { return html` <div class="shared-directories-table"> + ${msg && html`<p class="settings-hint">${msg}</p>`} <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p> - <button class="btn btn-small btn-secondary" style="margin-top:8px" - onClick=${doAddRoot}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> + ${addControls} </div> `; } @@ -342,15 +426,16 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, <thead> <tr> <th class="sdt-col-dir">${t('node.directory')}</th> - ${!readOnly && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`} - ${!readOnly && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`} + <th class="sdt-col-path">${t('node.root_path')}</th> + ${canEdit && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`} + ${canEdit && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`} <th class="sdt-col-actions"></th> </tr> </thead> <tbody> ${displayRoots.map(r => { const rowClass = r.ejected ? 'sdt-row-ejected' - : (!isLocal && !r.available) ? 'sdt-row-unavail' : ''; + : (!isLocal && r.available === false) ? 'sdt-row-unavail' : ''; return html` <tr class=${rowClass} key=${r.name}> <td class="sdt-col-dir"> @@ -360,30 +445,38 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, </span> ${r.ejected && html` <span class="node-root-badge node-root-badge-warn">${t('group.root_ejected')}</span>`} - ${!isLocal && !r.available && !r.ejected && html` + ${!isLocal && r.available === false && !r.ejected && html` <span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`} </td> - ${!readOnly && html` + ${/* Two roots can never share a name, so the name is the identity — + but it is the *basename*, and two libraries under different + parents look identical without this. */''} + <td class="sdt-col-path" title=${r.path || ''}>${r.path || ''}</td> + ${canEdit && html` <td class="sdt-col-toggle"> <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected} onChange=${(v) => doUpdateRoot(r.name, { writable: v })} /> </td> `} - ${!readOnly && !isLocal && html` + ${canEdit && !isLocal && html` <td class="sdt-col-toggle"> <${ToggleSwitch} checked=${!!r.removable} disabled=${busy} onChange=${(v) => doUpdateRoot(r.name, { removable: v })} /> </td> `} <td class="sdt-col-actions"> - ${!readOnly && !isLocal && html` + ${canEdit && !isLocal && html` <button class="sdt-action-btn" disabled=${busy || !r.removable} title=${r.ejected ? t('group.root_plug') : t('group.root_eject')} onClick=${() => r.ejected ? doPlugRoot(r.name) : doEjectRoot(r.name)}> ${r.ejected ? '\u{1F50C}' : '\u{23CF}'} </button> - <button class="sdt-action-btn sdt-action-danger" disabled=${busy} - title=${t('node.remove_root')} + `} + ${canEdit && html` + <button class="sdt-action-btn sdt-action-danger" + disabled=${busy || displayRoots.length < 2} + title=${displayRoots.length < 2 + ? t('node.root_remove_last') : t('node.remove_root')} onClick=${() => doRemoveRoot(r.name)}> \u{2715} </button> @@ -393,13 +486,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, `; })} </tbody> </table> - ${!readOnly && (isLocal || nodeAvail) && html` - <button class="btn btn-small btn-secondary" style="margin-top:8px" - disabled=${busy} - onClick=${doAddRoot}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> - `} + ${addControls} ${indexProgress && indexProgress.scanning && html` <div class="index-progress" style="margin-top:8px"> <div class="index-progress-bar"> @@ -435,6 +522,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, */ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, isNodeAdmin, userId, operatorPaired, connected, + mnpRoots, enabledApps, onEnabledApps, scanSettings, onScanSettings, tmdbConfig, onTmdbConfig, onTmdbEnabled, @@ -461,6 +549,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // platform.watchIndexProgress. const [nodeIndexProgress, setNodeIndexProgress] = useState(null); + // The roots to show, from whichever source can actually answer. + // + // `mnpRoots` comes from the index payload the node pushes over the live + // connection, and is the only source an operator signing in from an + // ordinary browser has. `nodeRoots` comes from the loopback API and exists + // only on the machine running the node. Preferring MNP when connected also + // keeps this table on the same data Files and the apps read, so an eject + // shows in one place at the same instant it shows in the other. + const effectiveRoots = (connected && mnpRoots && mnpRoots.length) + ? mnpRoots : nodeRoots; + + // Declared here rather than inline at the call site: a function rebuilt on + // every render is a new prop identity every render, and the callbacks that + // close over it in the table below are memoised on it. + const adminSignFn = useCallback((transcript) => { + const sk = transportRef.current && transportRef.current.sessionKeys + && transportRef.current.sessionKeys.skEdB64; + if (!sk || !window.MeshBayKeys) { + throw new Error(t('node.root_no_signing_key')); + } + return window.MeshBayKeys.signBytes(sk, transcript); + }, [transportRef]); + const loadNodeInfo = useCallback(async () => { if (!platform.node.available) return; try { @@ -1102,24 +1213,22 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </div> `} - ${/* Shared directories — the group's root folders. Shown to the - operator when the node is detected locally (Electron) or a live - MNP connection is available, so root properties can be toggled. - Appears early because it is the fundamental structural control. */ - isNodeAdmin && (connected || nodeDetected) && nodeRoots.length > 0 && html` + ${/* Shared directories — the group's root folders, and the structural + control everything else in this page sits on top of, so it comes + first. Rendered whenever the operator has a route to their node: + a live MNP connection (any browser, anywhere) or the loopback API + (the node on this machine). It used to require the second, which + meant it rendered for nobody on the web. */ + isNodeAdmin && (connected || nodeDetected) && html` <${CollapsibleSection} titleKey="settings_node.shared_directories_title"> <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p> + ${!connected && nodeDetected && html` + <p class="settings-hint">${t('settings_node.roots_offline_hint')}</p>`} <${SharedDirectoriesTable} - roots=${nodeRoots} + roots=${effectiveRoots} groupId=${groupId} transport=${transportRef.current} - signFn=${(() => { - const sk = transportRef.current && transportRef.current.sessionKeys - && transportRef.current.sessionKeys.skEdB64; - return (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - })()} + signFn=${adminSignFn} nodeDetected=${nodeDetected} onRootsChange=${loadNodeInfo} onRefreshIndex=${onRefreshIndex} /> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index f929873..ea14814 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -169,6 +169,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(nicht verfügbar — Laufwerk getrennt)', + 'group.root_plug': 'Einstecken', + 'group.root_eject': 'Auswerfen', + 'group.root_writable': 'Lesen/Schreiben', + 'group.root_ejected': '(ausgeworfen)', 'group.view': 'Ansehen', 'group.delete': 'Löschen', 'group.delete_confirm': '{name} löschen?', @@ -195,6 +199,7 @@ export default { 'chat.placeholder': 'Nachricht schreiben …', 'chat.send': 'Senden', 'chat.attach': 'Datei anhängen', + 'chat.attach_read_only': 'Kein beschreibbares freigegebenes Verzeichnis — Anhänge sind aus', // Video player 'video.loading': '{name} wird geladen …', @@ -722,10 +727,16 @@ export default { 'node.roots': 'Verzeichnisse', 'node.add_root': 'Verzeichnis hinzufügen', 'node.remove_root': 'Entfernen', + 'node.root_no_signing_key': 'Kein Signaturschlüssel verfügbar — koppeln Sie dieses Gerät zuerst mit dem Node', + 'node.root_no_route': 'Keine Verbindung zum Node — verbinden Sie sich damit oder verwenden Sie die App auf dem Rechner, der ihn hostet', + 'node.root_remove_last': 'Eine Gruppe braucht mindestens ein Verzeichnis', + 'node.root_path_hint': 'Der Pfad, wie der Node ihn sieht, auf dem Rechner, der diese Gruppe hostet.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Pfad', + 'node.directory': 'Verzeichnis', 'node.root_added': 'Verzeichnis hinzugefügt.', 'node.root_remove_confirm': '„{name}" aus dieser Gruppe entfernen?', 'node.root_removed': 'Verzeichnis entfernt. Neustart empfohlen, um den Index zu aktualisieren.', - 'node.upload_root': 'Uploads', 'node.attach_group': 'Gruppe hinzufügen', 'node.attach_pick': 'Zu hostende Gruppe', 'node.attach_dir': 'Freigegebenes Verzeichnis', @@ -807,6 +818,7 @@ export default { 'settings_node.photo_roots_save': 'Speichern', 'settings_node.shared_directories_title': 'Freigegebene Verzeichnisse', 'settings_node.shared_directories_hint': 'Ordner, die mit dieser Gruppe geteilt werden. Lesen/Schreiben umschalten, um Uploads zu erlauben. Als wechselbar markieren für externe Laufwerke.', + 'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.', 'settings_node.directories_title': 'App-Verzeichnisse', 'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos-, Musik- und Fotos-Apps als eigene(n) Einstiegspunkt(e) nutzen.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index f6c47fe..cbb790f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -198,6 +198,7 @@ export default { 'chat.placeholder': 'Type a message...', 'chat.send': 'Send', 'chat.attach': 'Attach file', + 'chat.attach_read_only': 'No writable shared directory — attachments are off', // Video player 'video.loading': 'Loading {name}...', @@ -605,6 +606,7 @@ export default { 'settings_node.photo_roots_save': 'Save', 'settings_node.shared_directories_title': 'Shared directories', 'settings_node.shared_directories_hint': 'Folders shared with this group. Toggle read-write to allow uploads, mark as removable for external drives.', + 'settings_node.roots_offline_hint': 'Not connected to the node — changes go through the local node instead, and take effect on its next reload.', 'settings_node.directories_title': 'App directories', 'settings_node.directories_hint': 'Which shared folders the Videos, Music and Photos apps use as their entry point(s).', @@ -759,7 +761,6 @@ export default { 'node.peers': { one: '1 peer', other: '{n} peers' }, 'node.no_gek': 'No group key', 'node.roots': 'Directories', - 'node.upload_root': 'uploads', 'node.directory': 'Directory', 'node.root_rw': 'Writable', 'node.root_ro': 'read-only', @@ -767,6 +768,12 @@ export default { 'node.unavailable': 'unavailable', 'node.add_root': 'Add directory', 'node.remove_root': 'Remove', + 'node.root_no_signing_key': 'No signing key available — pair this device with the node first', + 'node.root_no_route': 'No route to the node — connect to it, or use the app on the machine hosting it', + 'node.root_remove_last': 'A group needs at least one directory', + 'node.root_path_hint': 'The path as the node sees it, on the machine hosting this group.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Path', 'node.root_added': 'Directory added.', 'node.root_removed': 'Directory removed. Restart recommended to update the index.', 'node.root_remove_confirm': 'Remove "{name}" from this group?', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index a8c1296..3921532 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -167,6 +167,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(no disponible — la unidad está desconectada)', + 'group.root_plug': 'Conectar', + 'group.root_eject': 'Expulsar', + 'group.root_writable': 'Lectura/Escritura', + 'group.root_ejected': '(expulsado)', 'group.view': 'Ver', 'group.delete': 'Eliminar', 'group.delete_confirm': '¿Eliminar {name}?', @@ -193,6 +197,7 @@ export default { 'chat.placeholder': 'Escriba un mensaje...', 'chat.send': 'Enviar', 'chat.attach': 'Adjuntar archivo', + 'chat.attach_read_only': 'Ningún directorio compartido con escritura: los adjuntos están desactivados', // Video player 'video.loading': 'Cargando {name}...', @@ -718,10 +723,16 @@ export default { 'node.roots': 'Directorios', 'node.add_root': 'Añadir directorio', 'node.remove_root': 'Eliminar', + 'node.root_no_signing_key': 'No hay clave de firma disponible: empareja primero este dispositivo con el nodo', + 'node.root_no_route': 'No hay ruta al nodo: conéctate a él o usa la aplicación en la máquina que lo aloja', + 'node.root_remove_last': 'Un grupo necesita al menos un directorio', + 'node.root_path_hint': 'La ruta tal como la ve el nodo, en la máquina que aloja este grupo.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Ruta', + 'node.directory': 'Directorio', 'node.root_added': 'Directorio añadido.', 'node.root_remove_confirm': '¿Eliminar «{name}» de este grupo?', 'node.root_removed': 'Directorio eliminado. Se recomienda reiniciar para actualizar el índice.', - 'node.upload_root': 'subidas', 'node.attach_group': 'Añadir grupo', 'node.attach_pick': 'Grupo a alojar', 'node.attach_dir': 'Directorio compartido', @@ -803,6 +814,7 @@ export default { 'settings_node.photo_roots_save': 'Guardar', 'settings_node.shared_directories_title': 'Directorios compartidos', 'settings_node.shared_directories_hint': 'Carpetas compartidas con este grupo. Active lectura-escritura para permitir subidas, marque como extraíble para unidades externas.', + 'settings_node.roots_offline_hint': 'Sin conexión con el nodo: los cambios pasan por el nodo local y se aplican en su próxima recarga.', 'settings_node.directories_title': 'Directorios de apps', 'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos, Música y Fotos como su(s) propio(s) punto(s) de entrada.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 6d36e41..4e60eb9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -198,6 +198,7 @@ export default { 'chat.placeholder': 'Écrivez un message...', 'chat.send': 'Envoyer', 'chat.attach': 'Joindre un fichier', + 'chat.attach_read_only': 'Aucun répertoire partagé en écriture — pièces jointes désactivées', // Video player 'video.loading': 'Chargement de {name}...', @@ -731,10 +732,15 @@ export default { 'node.roots': 'Répertoires', 'node.add_root': 'Ajouter un répertoire', 'node.remove_root': 'Retirer', + 'node.root_no_signing_key': 'Aucune clé de signature disponible — appairez d\'abord cet appareil avec le nœud', + 'node.root_no_route': 'Aucune route vers le nœud — connectez-vous à lui, ou utilisez l\'application sur la machine qui l\'héberge', + 'node.root_remove_last': 'Un groupe a besoin d\'au moins un répertoire', + 'node.root_path_hint': 'Le chemin tel que le nœud le voit, sur la machine qui héberge ce groupe.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Chemin', 'node.root_added': 'Répertoire ajouté.', 'node.root_remove_confirm': 'Retirer « {name} » de ce groupe ?', 'node.root_removed': 'Répertoire retiré. Un redémarrage est recommandé pour mettre à jour l\'index.', - 'node.upload_root': 'uploads', 'node.directory': 'Répertoire', 'node.root_rw': 'Écriture', 'node.root_ro': 'lecture seule', @@ -826,6 +832,7 @@ export default { 'settings_node.photo_roots_save': 'Enregistrer', 'settings_node.shared_directories_title': 'Répertoires partagés', 'settings_node.shared_directories_hint': 'Dossiers partagés avec ce groupe. Activez lecture-écriture pour autoriser les envois, marquez comme amovible pour les disques externes.', + 'settings_node.roots_offline_hint': 'Non connecté au nœud — les changements passent par le nœud local et prennent effet à son prochain rechargement.', 'settings_node.directories_title': 'Répertoires des applications', 'settings_node.directories_hint': 'Quel(s) dossier(s) partagés les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 508d69c..38fc241 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -168,6 +168,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(non disponibile — l’unità è scollegata)', + 'group.root_plug': 'Ricollega', + 'group.root_eject': 'Espelli', + 'group.root_writable': 'Lettura/Scrittura', + 'group.root_ejected': '(espulso)', 'group.view': 'Visualizza', 'group.delete': 'Elimina', 'group.delete_confirm': 'Eliminare {name}?', @@ -194,6 +198,7 @@ export default { 'chat.placeholder': 'Scriva un messaggio...', 'chat.send': 'Invia', 'chat.attach': 'Allega un file', + 'chat.attach_read_only': 'Nessuna directory condivisa scrivibile: gli allegati sono disattivati', // Video player 'video.loading': 'Caricamento di {name}...', @@ -726,10 +731,16 @@ export default { 'node.roots': 'Directory', 'node.add_root': 'Aggiungi directory', 'node.remove_root': 'Rimuovi', + 'node.root_no_signing_key': 'Nessuna chiave di firma disponibile: associa prima questo dispositivo al nodo', + 'node.root_no_route': 'Nessuna via verso il nodo: connettiti a esso oppure usa l\'applicazione sulla macchina che lo ospita', + 'node.root_remove_last': 'Un gruppo ha bisogno di almeno una directory', + 'node.root_path_hint': 'Il percorso come lo vede il nodo, sulla macchina che ospita questo gruppo.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Percorso', + 'node.directory': 'Directory', 'node.root_added': 'Directory aggiunta.', 'node.root_remove_confirm': 'Rimuovere «{name}» da questo gruppo?', 'node.root_removed': "Directory rimossa. Si consiglia un riavvio per aggiornare l'indice.", - 'node.upload_root': 'caricamenti', 'node.attach_group': 'Aggiungi gruppo', 'node.attach_pick': 'Gruppo da ospitare', 'node.attach_dir': 'Directory condivisa', @@ -817,6 +828,7 @@ export default { 'settings_node.photo_roots_save': 'Salva', 'settings_node.shared_directories_title': 'Directory condivise', 'settings_node.shared_directories_hint': 'Cartelle condivise con questo gruppo. Attiva lettura-scrittura per consentire il caricamento, segna come rimovibile per unità esterne.', + 'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.', 'settings_node.directories_title': 'Directory delle app', 'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video, Musica e Foto usano come proprio/i punto/i di ingresso.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index cfd65da..7890ab7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -166,6 +166,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(利用不可 — ドライブが切断されています)', + 'group.root_plug': '接続する', + 'group.root_eject': '取り外す', + 'group.root_writable': '読み書き可', + 'group.root_ejected': '(取り外し済み)', 'group.view': '表示', 'group.delete': '削除', 'group.delete_confirm': '{name} を削除しますか?', @@ -191,6 +195,7 @@ export default { 'chat.placeholder': 'メッセージを入力…', 'chat.send': '送信', 'chat.attach': 'ファイルを添付', + 'chat.attach_read_only': '書き込み可能な共有ディレクトリがありません — 添付は無効です', // Video player 'video.loading': '{name} を読み込んでいます…', @@ -712,10 +717,16 @@ export default { 'node.roots': 'ディレクトリ', 'node.add_root': 'ディレクトリを追加', 'node.remove_root': '削除', + 'node.root_no_signing_key': '署名鍵がありません — 先にこの端末をノードとペアリングしてください', + 'node.root_no_route': 'ノードへの経路がありません — 接続するか、ノードを動かしているマシンでアプリを使ってください', + 'node.root_remove_last': 'グループには少なくとも 1 つのディレクトリが必要です', + 'node.root_path_hint': 'このグループをホストしているマシン上で、ノードから見たパスです。', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'パス', + 'node.directory': 'ディレクトリ', 'node.root_added': 'ディレクトリを追加しました。', 'node.root_remove_confirm': '「{name}」をこのグループから削除しますか?', 'node.root_removed': 'ディレクトリを削除しました。インデックスを更新するために再起動を推奨します。', - 'node.upload_root': 'アップロード', 'node.attach_group': 'グループを追加', 'node.attach_pick': 'ホストするグループ', 'node.attach_dir': '共有ディレクトリ', @@ -801,6 +812,7 @@ export default { 'settings_node.photo_roots_save': '保存', 'settings_node.shared_directories_title': '共有ディレクトリ', 'settings_node.shared_directories_hint': 'このグループと共有されているフォルダー。読み書きを切り替えてアップロードを許可し、外付けドライブにはリムーバブルを設定します。', + 'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。', 'settings_node.directories_title': 'アプリのディレクトリ', 'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 0569efb..cdd4720 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -169,6 +169,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(niet beschikbaar — de schijf is losgekoppeld)', + 'group.root_plug': 'Aansluiten', + 'group.root_eject': 'Uitwerpen', + 'group.root_writable': 'Lezen/schrijven', + 'group.root_ejected': '(uitgeworpen)', 'group.view': 'Bekijken', 'group.delete': 'Verwijderen', 'group.delete_confirm': '{name} verwijderen?', @@ -195,6 +199,7 @@ export default { 'chat.placeholder': 'Typ een bericht...', 'chat.send': 'Versturen', 'chat.attach': 'Bestand bijvoegen', + 'chat.attach_read_only': 'Geen beschrijfbare gedeelde map — bijlagen staan uit', // Video player 'video.loading': '{name} wordt geladen...', @@ -728,10 +733,16 @@ export default { 'node.roots': 'Mappen', 'node.add_root': 'Map toevoegen', 'node.remove_root': 'Verwijderen', + 'node.root_no_signing_key': 'Geen ondertekeningssleutel beschikbaar — koppel dit apparaat eerst aan de node', + 'node.root_no_route': 'Geen route naar de node — maak verbinding, of gebruik de app op de machine die hem host', + 'node.root_remove_last': 'Een groep heeft minstens één map nodig', + 'node.root_path_hint': 'Het pad zoals de node het ziet, op de machine die deze groep host.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Pad', + 'node.directory': 'Map', 'node.root_added': 'Map toegevoegd.', 'node.root_remove_confirm': '„{name}" uit deze groep verwijderen?', 'node.root_removed': 'Map verwijderd. Herstart aanbevolen om de index bij te werken.', - 'node.upload_root': 'uploads', 'node.attach_group': 'Groep toevoegen', 'node.attach_pick': 'Groep om te hosten', 'node.attach_dir': 'Gedeelde map', @@ -819,6 +830,7 @@ export default { 'settings_node.photo_roots_save': 'Opslaan', 'settings_node.shared_directories_title': 'Gedeelde mappen', 'settings_node.shared_directories_hint': 'Mappen gedeeld met deze groep. Schakel lezen-schrijven in om uploads toe te staan, markeer als verwijderbaar voor externe schijven.', + 'settings_node.roots_offline_hint': 'Niet verbonden met de node — wijzigingen gaan via de lokale node en worden bij de volgende herlaadbeurt actief.', 'settings_node.directories_title': 'App-mappen', 'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s-, Muziek- en Foto\'s-apps als eigen startpunt(en) gebruiken.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 92c853b..d3826f5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -172,6 +172,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(niedostępne — dysk jest odłączony)', + 'group.root_plug': 'Podłącz', + 'group.root_eject': 'Odłącz', + 'group.root_writable': 'Odczyt/zapis', + 'group.root_ejected': '(odłączony)', 'group.view': 'Podgląd', 'group.delete': 'Usuń', 'group.delete_confirm': 'Usunąć {name}?', @@ -200,6 +204,7 @@ export default { 'chat.placeholder': 'Napisz wiadomość...', 'chat.send': 'Wyślij', 'chat.attach': 'Załącz plik', + 'chat.attach_read_only': 'Brak zapisywalnego katalogu współdzielonego — załączniki wyłączone', // Video player 'video.loading': 'Wczytywanie {name}...', @@ -750,10 +755,16 @@ export default { 'node.roots': 'Katalogi', 'node.add_root': 'Dodaj katalog', 'node.remove_root': 'Usuń', + 'node.root_no_signing_key': 'Brak klucza podpisu — najpierw sparuj to urządzenie z węzłem', + 'node.root_no_route': 'Brak połączenia z węzłem — połącz się z nim albo użyj aplikacji na komputerze, który go hostuje', + 'node.root_remove_last': 'Grupa wymaga co najmniej jednego katalogu', + 'node.root_path_hint': 'Ścieżka widziana przez węzeł, na komputerze hostującym tę grupę.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Ścieżka', + 'node.directory': 'Katalog', 'node.root_added': 'Katalog dodany.', 'node.root_remove_confirm': 'Usunąć „{name}" z tej grupy?', 'node.root_removed': 'Katalog usunięty. Zalecany restart w celu odświeżenia indeksu.', - 'node.upload_root': 'przesyłanie', 'node.attach_group': 'Dodaj grupę', 'node.attach_pick': 'Grupa do hostowania', 'node.attach_dir': 'Katalog współdzielony', @@ -845,6 +856,7 @@ export default { 'settings_node.photo_roots_save': 'Zapisz', 'settings_node.shared_directories_title': 'Katalogi udostępnione', 'settings_node.shared_directories_hint': 'Foldery udostępnione tej grupie. Przełącz odczyt-zapis, aby zezwolić na przesyłanie, oznacz jako wymienny dla dysków zewnętrznych.', + 'settings_node.roots_offline_hint': 'Brak połączenia z węzłem — zmiany przechodzą przez węzeł lokalny i zaczną działać po jego następnym przeładowaniu.', 'settings_node.directories_title': 'Katalogi aplikacji', 'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo, Muzyka i Zdjęcia traktują jako własny punkt (punkty) wejścia.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index cd5f7e8..6706c13 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -169,6 +169,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(indisponível — a unidade está desconectada)', + 'group.root_plug': 'Conectar', + 'group.root_eject': 'Ejetar', + 'group.root_writable': 'Leitura/Escrita', + 'group.root_ejected': '(ejetado)', 'group.view': 'Visualizar', 'group.delete': 'Excluir', 'group.delete_confirm': 'Excluir {name}?', @@ -195,6 +199,7 @@ export default { 'chat.placeholder': 'Escreva uma mensagem...', 'chat.send': 'Enviar', 'chat.attach': 'Anexar arquivo', + 'chat.attach_read_only': 'Nenhum diretório compartilhado gravável — anexos desativados', // Video player 'video.loading': 'Carregando {name}...', @@ -719,10 +724,16 @@ export default { 'node.roots': 'Diretórios', 'node.add_root': 'Adicionar diretório', 'node.remove_root': 'Remover', + 'node.root_no_signing_key': 'Nenhuma chave de assinatura disponível — pareie este dispositivo com o nó primeiro', + 'node.root_no_route': 'Sem rota até o nó — conecte-se a ele ou use o aplicativo na máquina que o hospeda', + 'node.root_remove_last': 'Um grupo precisa de pelo menos um diretório', + 'node.root_path_hint': 'O caminho como o nó o vê, na máquina que hospeda este grupo.', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': 'Caminho', + 'node.directory': 'Diretório', 'node.root_added': 'Diretório adicionado.', 'node.root_remove_confirm': 'Remover "{name}" deste grupo?', 'node.root_removed': 'Diretório removido. Reinicialização recomendada para atualizar o índice.', - 'node.upload_root': 'uploads', 'node.attach_group': 'Adicionar grupo', 'node.attach_pick': 'Grupo a hospedar', 'node.attach_dir': 'Diretório compartilhado', @@ -804,6 +815,7 @@ export default { 'settings_node.photo_roots_save': 'Salvar', 'settings_node.shared_directories_title': 'Diretórios compartilhados', 'settings_node.shared_directories_hint': 'Pastas compartilhadas com este grupo. Alterne leitura-escrita para permitir uploads, marque como removível para unidades externas.', + 'settings_node.roots_offline_hint': 'Sem conexão com o nó — as alterações passam pelo nó local e entram em vigor no próximo recarregamento.', 'settings_node.directories_title': 'Diretórios de apps', 'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos, Música e Fotos tratam como seu(s) próprio(s) ponto(s) de entrada.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index b80ac08..f62d6fd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -165,6 +165,10 @@ export default { 'device.approve_btn': 'Approve', 'device.approved': 'Device linked.', 'group.root_unavailable': '(不可用 — 驱动器已断开连接)', + 'group.root_plug': '重新接入', + 'group.root_eject': '弹出', + 'group.root_writable': '读写', + 'group.root_ejected': '(已弹出)', 'group.view': '查看', 'group.delete': '删除', 'group.delete_confirm': '删除 {name}?', @@ -189,6 +193,7 @@ export default { 'chat.placeholder': '输入消息…', 'chat.send': '发送', 'chat.attach': '添加附件', + 'chat.attach_read_only': '没有可写的共享目录 — 附件已停用', // Video player 'video.loading': '正在加载 {name}…', @@ -699,10 +704,16 @@ export default { 'node.roots': '目录', 'node.add_root': '添加目录', 'node.remove_root': '移除', + 'node.root_no_signing_key': '没有可用的签名密钥 — 请先将本设备与节点配对', + 'node.root_no_route': '无法连接到节点 — 请先连接,或在运行该节点的机器上使用应用', + 'node.root_remove_last': '每个群组至少需要一个目录', + 'node.root_path_hint': '托管该群组的机器上,节点所看到的路径。', + 'node.root_path_placeholder': '/home/user/Media', + 'node.root_path': '路径', + 'node.directory': '目录', 'node.root_added': '目录已添加。', 'node.root_remove_confirm': '从此群组中移除"{name}"?', 'node.root_removed': '目录已移除。建议重启以更新索引。', - 'node.upload_root': '上传目录', 'node.attach_group': '添加群组', 'node.attach_pick': '要托管的群组', 'node.attach_dir': '共享目录', @@ -788,6 +799,7 @@ export default { 'settings_node.photo_roots_save': '保存', 'settings_node.shared_directories_title': '共享目录', 'settings_node.shared_directories_hint': '与此群组共享的文件夹。切换读写以允许上传,标记为可移除用于外置驱动器。', + 'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。', 'settings_node.directories_title': '应用目录', 'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index ec73128..2e216a9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -750,13 +750,21 @@ export function NodePage({ groups }) { <${Icon} name="folder" /> ${r.name} </span> - ${r.upload && html` - <span class="node-root-badge">${t('node.upload_root')}</span>`} - ${!r.available && html` + ${r.writable && html` + <span class="node-root-badge">${t('node.root_rw')}</span>`} + ${r.removable && html` + <span class="node-root-badge">${t('node.removable')}</span>`} + ${r.ejected ? html` + <span class="node-root-badge node-root-badge-warn"> + ${t('group.root_ejected')}</span>` + : !r.available && html` <span class="node-root-badge node-root-badge-warn"> ${t('node.unavailable')}</span>`} </div> - ${(g.roots || []).length > 1 && !r.upload && html` + ${/* Removing a writable root is allowed now — several can be + writable, and a group with none is a valid read-only + group. The last root is still the one that cannot go. */''} + ${(g.roots || []).length > 1 && html` <button class="btn btn-small btn-danger" disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}> ${t('node.remove_root')}</button>`} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index 608bb81..6e4e5b9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -707,7 +707,6 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) applyIndex=${noop} isNodeAdmin=${false} operatorPaired=${false} - mayUpload=${false} userId=${userId} setError=${noop} onPreview=${onPreview} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 51f9d70..eaf1298 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2554,6 +2554,21 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } .shared-dirs-tbl td { padding: 7px 12px 7px 0; border: none; vertical-align: middle; } .shared-dirs-tbl tbody tr + tr td { border-top: 1px solid var(--border); } .sdt-col-dir { min-width: 140px; } +/* The path is the only thing separating two libraries whose folders happen to + share a basename, so it is shown — truncated, because it is usually long and + rarely the thing being read. */ +.sdt-col-path { + color: var(--text-dim); font-size: 0.85em; + max-width: 260px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} +.sdt-add-row { display: flex; gap: 6px; align-items: center; margin-top: 8px; } +.sdt-add-input { + flex: 1 1 auto; min-width: 0; padding: 5px 8px; + border: 1px solid var(--border); border-radius: 4px; + background: var(--bg-input, transparent); color: var(--text); + font-family: inherit; font-size: 0.9em; +} .sdt-col-toggle { width: 90px; text-align: center; } .sdt-col-toggle th { text-align: center; } .sdt-col-toggle .toggle-switch { justify-content: center; } @@ -2577,6 +2592,16 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } line-height: 1; } .root-eject-btn:hover { background: var(--bg-hover); } +.root-eject-btn { margin-left: 8px; } +/* An ejected root in the Files table: still listed, deliberately — its files + are frozen, not gone — but not somewhere you can walk into. */ +.file-row.root-ejected { opacity: 0.5; } +.file-row.root-ejected td:not(.sel-cell) { cursor: default; } + +/* The paperclip with no writable directory to write to. Shown rather than + hidden, so the reason is discoverable instead of the control just being + absent. */ +.chat-attach-off { opacity: 0.35; cursor: not-allowed; } .node-root { display: flex; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 482574f..8df7700 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -72,11 +72,18 @@ function _aborted() { // change anything — `op` is already on every admin_challenge, and this // list is what lets a response two steps later be tied back to the right // one. +// The acks whose payload is state no caller could have predicted: they carry +// the node's whole roots table back. See the note where they are dispatched. +const ROOT_ACK_TYPES = new Set([ + 'root_update_ack', 'root_eject_ack', 'root_plug_ack', + 'root_add_ack', 'root_remove_ack', +]); + const ADMIN_OP_TYPES = new Set([ 'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', 'photo_roots', 'musicbrainz_enabled', 'file_delete', 'dir_delete', - 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', + 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', @@ -1072,7 +1079,7 @@ class MeshBayTransport { * queried in (e.g. "fr-FR") — one for the whole node, since both are one * operator's shared credential/cache, not a per-group concern (see * setTmdbEnabled below for the per-group on/off switch). Signed like - * setAppsEnabled/setMemberUpload — an unsigned change would let any + * setAppsEnabled/updateRoot — an unsigned change would let any * member alter outbound third-party network traffic the operator never * agreed to (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears * a previously-set custom token; omit it (undefined/null), like @@ -1367,24 +1374,6 @@ class MeshBayTransport { * on the hub is the other half, and neither implies the other. */ /** - * Turn uploading by ordinary members on or off. - * - * Signed by the operator like any other privileged operation — the node - * refuses an unsigned one, which is what stops a member turning it back on. - */ - async setMemberUpload(allowed, signFn) { - const msg = await this._sendAndWait({ - type: 'member_upload', v: '0.1', allowed: Boolean(allowed), - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - return this._authorizeAdminOp( - msg, 'member_upload', allowed ? 'on' : 'off', signFn); - } - return msg; - } - - /** * Turn a group "application" (Chat, Files, ...) on or off for everyone. * * Takes the whole set in one signed message rather than one op per app, so @@ -1393,13 +1382,25 @@ class MeshBayTransport { * `_authorizeAdminOp` below checks the two match. */ async setAppsEnabled(apps, signFn) { + // Files cannot be turned off — MNP permits root exploration regardless of + // this list, so hiding the tab only ever misled — and the node adds it if + // it is missing. That normalisation has to happen *here too*: the subject + // below is rebuilt from what this client sent, and compared byte for byte + // against what the node put in the challenge. A list arriving here without + // `files` would produce two different strings and `_authorizeAdminOp` + // would refuse to sign an op the operator did ask for. It is reachable + // only from a caller that builds the list from something other than the + // node's own answer, which is exactly the kind of caller a later phase + // adds. (`apps.js` marks it `alwaysEnabled`; this file is a classic + // script and cannot import it.) + const full = apps.includes('files') ? [...apps] : ['files', ...apps]; const msg = await this._sendAndWait({ - type: 'apps_enabled', v: '0.1', apps, + type: 'apps_enabled', v: '0.1', apps: full, }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { return this._authorizeAdminOp( - msg, 'apps_enabled', [...apps].sort().join(','), signFn); + msg, 'apps_enabled', [...full].sort().join(','), signFn); } return msg; } @@ -1657,8 +1658,14 @@ class MeshBayTransport { * The node decides where this lands (uploads/) and under what name — it finds a * free one rather than replacing anything. The ack says which, and that is what * this returns. + * + * `root` names which shared directory to upload into — a name, never a path; + * the node picks the destination inside it. Since a group can have several + * writable roots, leaving it out is a guess, and the node's fallback ("the + * first writable one") exists only for MNP 1.0 clients, which had exactly one + * destination. Every caller here browses a root and knows which one it is. */ - async uploadFile(file, { chunkSize, onProgress, signal } = {}) { + async uploadFile(file, { chunkSize, onProgress, signal, root } = {}) { // The same file twice at once would confuse the node, which keys its own // upload state by name — and would race for the same destination. if (this._uploaders.has(file.name)) { @@ -1709,6 +1716,7 @@ class MeshBayTransport { chunk_index: i, total_chunks: total, data: buf, + ...(root ? { root } : {}), }); } while (acked < total) { @@ -2206,7 +2214,21 @@ class MeshBayTransport { } else if (typeof msg.type === 'string' && msg.type.endsWith('_ack')) { const key = `admin:${msg.type.slice(0, -4)}`; for (const [, handler] of this._pending) { - if (handler._key === key) { handler.resolve(msg); return; } + if (handler._key === key) { + handler.resolve(msg); + // The comment above ("its own caller already updates local state + // from what it sent") is true of every op whose caller passes the + // value it just chose to an onX(next). The root ops are not like + // that: what changes is the whole roots table, which only the node + // can compute — availability, the eject that the plug refused, the + // name it settled on. Returning here left the operator who clicked + // Eject as the one client that never saw it happen, while every + // other peer got the broadcast. So this one type is handed on. + if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) { + this._onRootsChanged(msg); + } + return; + } } } @@ -2253,10 +2275,12 @@ class MeshBayTransport { return; } - // The operator changed who may upload. Unsolicited: it arrives at everyone - // connected, not only at whoever asked. It still has to reach a pending - // caller — the operator's own request resolves on this reply — so it falls - // through to the matching below rather than returning here. + // 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)); } @@ -2322,10 +2346,10 @@ class MeshBayTransport { this._onMusicbrainzEnabled(Boolean(msg.enabled)); } - // A root's writable/removable flags changed, or a root was ejected/plugged. - // Broadcast to all peers so everyone sees the change. - if ((msg.type === 'root_update_ack' || msg.type === 'root_eject_ack' - || msg.type === 'root_plug_ack') && this._onRootsChanged) { + // A root's flags changed, or one was ejected, plugged, added or removed. + // Broadcast by the node to every peer, so everyone's table updates without + // waiting for the next index_sync. + if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) { this._onRootsChanged(msg); } diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py index 94917d2..ae1f444 100644 --- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py +++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py @@ -1,14 +1,22 @@ """ -When the operator closes uploading, the controls go — both of them. +When a directory is read-only, the controls that write to it go — both of them. -There are two ways to put a file into a group and they are in different +There are two ways to put a file into a group and they live in different components: the Upload button in the Files toolbar, and the paperclip in the chat composer. Hiding one and forgetting the other is the obvious mistake, and -the second one is the easier to forget because it does not look like an upload. +the paperclip is the easier to forget because it does not look like an upload. Nothing here is a security property. **The node refuses the upload** — that is -`test_member_upload_policy.py` in the node package. This is about not offering -somebody a button whose only outcome is an error message. +`test_root_writable_policy.py` and `test_security_regressions.py` in the node +package. This is about not offering somebody a button whose only outcome is an +error message. + +What the RO/RW refactor changed: there is no group-wide answer any more. Files +uploads into *the root being browsed*, so its button follows that root's +`writable`. Chat has no folder on screen, so the shell picks one for it. The +two therefore read different things on purpose, and the tests below pin that +each reads the right one — a stronger claim than the old "both read one +boolean", which is why that assertion is gone rather than adapted. """ import re @@ -17,10 +25,6 @@ from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -# The group-page refactor split what used to be one app.js into one file per -# "application" plus the group shell. mayUpload itself is still derived once, -# in the shell (group-page.js) — Files and Chat each moved to their own file -# and receive it as a prop, the same shape ChatPanel already took. APP = STATIC / "app.js" GROUP_PAGE = STATIC / "group-page.js" FILES_APP = STATIC / "files-app.js" @@ -36,45 +40,93 @@ def app() -> str: return GROUP_PAGE.read_text(encoding="utf-8") -def _component(app: str, name: str) -> str: - start = app.index(f"\nfunction {name}(") - end = app.find("\nfunction ", start + 1) - return app[start:end if end != -1 else len(app)] +def _component(source: str, name: str) -> str: + start = source.index(f"\nfunction {name}(") + end = source.find("\nfunction ", start + 1) + return source[start:end if end != -1 else len(source)] # ── Both controls ─────────────────────────────────────────────────────────── def test_the_files_toolbar_hides_its_upload_button(): + """ + Gated on the root being browsed, not on a group-wide answer: with one + writable root and one read-only one, a single boolean would offer the + button in both and produce a refusal in one of them. + """ + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") + toolbar = page[page.index("file-toolbar"):] + toolbar = toolbar[:toolbar.index("breadcrumbs")] + assert "currentRootWritable" in toolbar, ( + "the Upload button is offered regardless of the directory's own flag") + + +def test_the_files_upload_button_is_not_offered_at_the_top_of_a_group(): + """ + The top level is the set of roots, which is the operator's configuration + and not a directory on anyone's disk. There is nothing to upload *into* + there, and no root name to give the node. + """ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") toolbar = page[page.index("file-toolbar"):] toolbar = toolbar[:toolbar.index("breadcrumbs")] - assert "mayUpload &&" in toolbar, "the Upload button is offered regardless" + assert "currentPath &&" in toolbar def test_the_chat_composer_hides_its_paperclip(): chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel") composer = chat[chat.index("chat-input-row"):] - assert "mayUpload &&" in composer, ( + assert "attachRoot ?" in composer, ( "the chat attachment is the second way in and is still offered") -def test_both_read_the_same_answer(app): - """Two derivations would eventually disagree, and the disagreement would - be one of them offering an upload the node refuses.""" - assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", app), ( - "mayUpload is no longer derived in one place") - # Files and Chat both receive it from the same `commonProps` object the - # shell spreads into whichever app tab is active — one derivation feeding - # one object, rather than two hand-written prop attributes that could - # drift apart. +def test_the_paperclip_says_why_rather_than_vanishing(): + """ + A control that disappears leaves the reader no way to find out what would + bring it back. A group with no writable directory is a state an operator + can fix, so it is worth naming. + """ + chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel") + composer = chat[chat.index("chat-input-row"):] + assert "chat.attach_read_only" in composer + + +# ── One derivation, in the shell ──────────────────────────────────────────── + +def test_the_attachment_directory_is_decided_once(app): + """ + Two derivations would eventually disagree, and the disagreement would be + one of them offering an upload the node refuses. + """ + assert re.search(r"const attachRoot = ", app), ( + "attachRoot is no longer derived in one place") props = app[app.index("const commonProps = {"):app.index("return html`")] - assert "mayUpload," in props or "mayUpload:" in props, ( - "mayUpload is not in the shared props object every app receives") + assert "attachRoot," in props or "attachRoot:" in props, ( + "attachRoot is not in the shared props object every app receives") + +def test_an_unavailable_root_is_not_offered_as_a_destination(app): + """ + `writable` is configuration and stays true while a drive is unplugged or + ejected. Offering it anyway produces a refusal from the node with no + explanation on screen. + """ + block = app[app.index("const writableRoots"):] + block = block[:block.index("const attachRoot")] + assert "available" in block -def test_the_operator_keeps_their_own_controls(app): - assert "memberUpload || isNodeAdmin" in app, ( - "turning uploads off would hide the operator's own upload button") + +def test_files_uploads_into_the_root_it_is_showing(): + """ + The client has to name the destination now, because the node cannot choose + between several writable roots without guessing — and a guess here means a + file landing in a directory nobody was looking at. + """ + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") + upload = page[page.index("const uploadFile"):] + upload = upload[:upload.index("const makeDirectory")] + assert "root: uploadRoot" in upload, "the node is left to choose" + assert "currentPath.split('/')[0]" in upload # ── Learning the answer ───────────────────────────────────────────────────── @@ -82,54 +134,104 @@ def test_the_operator_keeps_their_own_controls(app): def test_the_answer_comes_from_the_node(app): """Not from the hub, which has no say in what may be written to someone else's disk, and no way to be believed about it.""" - assert "ack.member_upload !== false" in app, ( - "the handshake ack is what carries this") - assert "hubFetch" not in app[app.index("ack.member_upload") - 400: - app.index("ack.member_upload")] + assert "if (indexMsg.roots) setNodeRoots(indexMsg.roots)" in app, ( + "the roots table in the index payload is what carries this") + idx = app.index("setNodeRoots(indexMsg.roots)") + assert "hubFetch" not in app[idx - 400:idx] def test_an_older_node_is_treated_as_permissive(app): - """A node that predates the setting sends no such field. Reading a missing - field as "off" would close every group on the older half of the network.""" + """ + 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. + """ + 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") def test_a_change_reaches_people_already_connected(app): - """The operator may be someone else entirely, changing it while you have - the group open. A button that survives until the next reconnection is a - button somebody presses.""" - assert "transport.onUploadPolicy" in app + """ + The operator may be someone else entirely, ejecting a drive while you have + the group open. A file list that survives until the next reconnection is a + list somebody clicks. + """ + assert "transport.onRootsChanged" in app transport = TRANSPORT.read_text(encoding="utf-8") - assert "member_upload_ack" in transport, "nothing routes the node's notice" + assert "root_eject_ack" in transport, "nothing routes the node's notice" + +def test_the_notice_also_answers_the_operators_own_request(): + """ + The same message is both a broadcast and the reply to the request that + caused it. -def test_the_notice_still_answers_the_operators_own_request(app): - """The same message is both a broadcast and the reply to the request that - caused it — returning early on it would leave that request hanging until it - timed out.""" + Every other admin ack can be resolved and dropped, because its caller + already knows what it asked for and updates local state from that. The root + acks carry a whole table only the node can compute — availability, the name + it settled on, the eject a failed plug left in place — so resolving one + without handing it on left the operator who clicked Eject as the only + client that never saw it happen. + """ transport = TRANSPORT.read_text(encoding="utf-8") - # Scoped to member_upload_ack's own handler, not everything up to the next - # occurrence of "index_sync" — other handlers with their own, legitimate - # early `return` (index_progress, set_scan_settings_ack: neither is ever a - # reply anyone awaits) now sit between the two in the file. - block = transport[transport.index("member_upload_ack"):] - block = block[:block.index("apps_enabled_ack")] - assert "return" not in block + block = transport[transport.index("msg.type.endsWith('_ack')"):] + block = block[:block.index("_uploaders")] + assert "ROOT_ACK_TYPES" in block and "_onRootsChanged" in block, ( + "the initiating client resolves the ack and learns nothing from it") # ── Changing it ───────────────────────────────────────────────────────────── -def test_changing_it_is_signed(app): +def test_changing_a_root_is_signed(): transport = TRANSPORT.read_text(encoding="utf-8") - method = transport[transport.index("async setMemberUpload("):] - method = method[:method.index("\n async ", 1)] - assert "admin_challenge" in method and "_authorizeAdminOp" in method, ( - "an unsigned instruction would let any member turn uploads back on") + for method in ("updateRoot", "ejectRoot", "plugRoot"): + body = transport[transport.index(f"async {method}("):] + body = body[:body.index("\n async ", 1)] + assert "admin_challenge" in body and "_authorizeAdminOp" in body, ( + f"{method} is unsigned — any member could use it") def test_only_the_operator_is_offered_the_setting(): - panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel") - section = panel[panel.index("members.uploads_title") - 400: - panel.index("members.uploads_title")] - assert "isNodeAdmin && connected" in section + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "GroupSettingsPanel") + section = panel[panel.index("settings_node.shared_directories_title") - 600: + panel.index("settings_node.shared_directories_title")] + assert "isNodeAdmin &&" in section + + +def test_the_operator_is_offered_it_on_the_web_too(): + """ + An operator is not necessarily sitting at their node. The first version of + this section required the loopback API, which resolves to "not available" + in a browser — so it rendered for nobody on the web, while the upload + controls it replaced had worked there. + """ + source = GROUP_SETTINGS.read_text(encoding="utf-8") + panel = _component(source, "GroupSettingsPanel") + section = panel[panel.index("settings_node.shared_directories_title") - 600: + panel.index("settings_node.shared_directories_title")] + assert "connected ||" in section, ( + "the shared directories section still requires a local node") + + table = _component(source, "SharedDirectoriesTable") + for call in ("transport.updateRoot", "transport.ejectRoot", + "transport.plugRoot", "transport.removeRoot", + "transport.addRoot"): + assert call in table, f"{call} has no MNP route from the table" + + +def test_the_roots_shown_come_from_the_live_connection_when_there_is_one(): + """ + The loopback list is a second source, and the two drift: it is read once on + mount and after a change, while the MNP one is pushed. Preferring MNP also + keeps this table on the same data Files reads, so an eject shows in both at + the same instant. + """ + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "GroupSettingsPanel") + assert "const effectiveRoots = (connected && mnpRoots" in panel 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 diff --git a/packages/meshbay-node/tests/conftest.py b/packages/meshbay-node/tests/conftest.py index 20724aa..3dc9cd9 100644 --- a/packages/meshbay-node/tests/conftest.py +++ b/packages/meshbay-node/tests/conftest.py @@ -24,14 +24,18 @@ win32_todo = pytest.mark.skipif( ) -def one_root(path: Path, *, name: str = "", kind: str = "generic") -> RootSet: +def one_root(path: Path, *, name: str = "", kind: str = "generic", + writable: bool = True) -> RootSet: """ - A RootSet with a single root over `path`, receiving uploads. + A RootSet with a single writable root over `path`. The equivalent of the old `shared_dir`. Note what it implies for assertions: a file directly in `path` now has `entry.path == <basename of path>`, not `""` — every index path carries its root name, in a group with one root as much as in a group with five. + + Writable by default because most callers are testing something else and + want a root an upload can reach. `writable=False` is the read-only group. """ return RootSet.build([{"path": str(path), "name": name, "kind": kind, - "upload": True}]) + "writable": writable}]) diff --git a/packages/meshbay-node/tests/test_apps_enabled_policy.py b/packages/meshbay-node/tests/test_apps_enabled_policy.py index 671005a..ac44ab3 100644 --- a/packages/meshbay-node/tests/test_apps_enabled_policy.py +++ b/packages/meshbay-node/tests/test_apps_enabled_policy.py @@ -1,7 +1,7 @@ """ The operator decides which group "applications" (Chat, Files, ...) are shown. -Same shape as `test_member_upload_policy.py`, because it is the same kind of +Same shape as `test_root_writable_policy.py`, because it is the same kind of setting: changed by a signed operator instruction, stored on the node rather than the hub, and safe for an existing group to have never heard of. The two things specific to this one: the whole set is signed in one message rather @@ -88,7 +88,7 @@ async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): async def test_changing_it_needs_a_signature(tmp_path): """The request only ever produces a challenge. Nothing is applied until a - signature over the transcript verifies — the same path as member_upload.""" + signature over the transcript verifies — the same path as the root ops.""" session = _session(tmp_path, "op", operator="op") session._has_admin_authority = lambda: True issued = [] diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index f58c020..2ba251f 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -26,6 +26,15 @@ VERBS = [ ["status"], ["group", "list"], ["group", "add"], # missing --dir: usage, then exit + ["group", "add", "g", "--dir", "/tmp/media", "--no-writable"], + ["root", "list"], + ["root", "add"], # missing path: usage, then exit + ["root", "add", "/tmp/media", "--writable", "--removable"], + ["root", "remove", "media", "--yes"], + ["root", "set", "media", "--no-writable"], + ["root", "set", "media"], # nothing to change: usage, then exit + ["root", "eject", "media"], + ["root", "plug", "media"], ["gek", "init"], ["gek", "rotate", "--yes"], ["gek-init"], diff --git a/packages/meshbay-node/tests/test_member_upload_policy.py b/packages/meshbay-node/tests/test_member_upload_policy.py deleted file mode 100644 index b1dc0cb..0000000 --- a/packages/meshbay-node/tests/test_member_upload_policy.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -The operator can close uploading to everyone but themselves. - -The point of these tests is the difference between a hidden button and a closed -door. The interface stops offering the control, which is a courtesy to the -people who are not trying; **the node refuses the upload**, which is the part -that holds against someone who is. A member who kept an old tab open, or who -speaks MNP directly, gets the same answer as everyone else. - -Two further things are worth holding: - -* the setting is changed by a **signed** operator instruction. A node that took - it from an unsigned message would let any member turn it back on, and the - control would be a suggestion; -* it is stored on the **node**, not the hub. A hub that could decide who may - write to the operator's disk is a hub with authority over the node, which is - the thing this whole design is arranged to avoid. -""" - -import base64 -from pathlib import Path - -import pytest -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from meshbay_common.adminop import OP_MEMBER_UPLOAD -from meshbay_node.indexer.group_index import GroupIndex -from meshbay_node.roster import Roster -from meshbay_node.transport.webrtc_server import WebRTCPeerSession - -from conftest import one_root - -pytestmark = pytest.mark.asyncio - - -def _session(tmp_path: Path, user_id: str, *, member_upload: bool, - operator: str | None = None) -> WebRTCPeerSession: - shared_root = tmp_path / "shared" - shared_root.mkdir(exist_ok=True) - index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) - ctx = { - "roots": one_root(shared_root), - "index": index, - "sk_node": index.sk_node, - "member_upload": member_upload, - "node_user_id": operator, - } - session = WebRTCPeerSession.__new__(WebRTCPeerSession) - session._ctx = ctx - session._group_id = None - session._user_id = user_id - session._pk_user = "" - session._uploads = {} - session.sent = [] - session._send = session.sent.append - session._audit = lambda *a, **k: None - return session - - -def _upload(session, filename="clip.mp4", body=b"bytes"): - session._do_file_upload({ - "filename": filename, "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(body).decode(), - }) - - -def _uploads_dir(session) -> Path: - return session._ctx["roots"].upload_root.path / "uploads" - - -# ── The door, not the button ──────────────────────────────────────────────── - -async def test_a_member_cannot_upload_when_it_is_turned_off(tmp_path): - session = _session(tmp_path, "member-1", member_upload=False, - operator="the-operator") - _upload(session) - - assert not (_uploads_dir(session) / "clip.mp4").exists(), ( - "the file was written even though uploading is off — the setting is " - "decorative and the hidden button was the whole control") - refusal = [m for m in session.sent if m.get("type") == "error"] - assert refusal and refusal[0].get("code") == "member_upload_off" - - -async def test_the_operator_can_still_upload(tmp_path): - """Otherwise turning it off locks the operator out of their own node, and - the only way back is a config file and a restart.""" - session = _session(tmp_path, "the-operator", member_upload=False, - operator="the-operator") - _upload(session) - - assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" - - -async def test_members_upload_normally_when_it_is_on(tmp_path): - session = _session(tmp_path, "member-1", member_upload=True, - operator="the-operator") - _upload(session) - - assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" - - -async def test_a_node_that_never_heard_of_the_setting_still_accepts_uploads(tmp_path): - """An existing node's context has no such key. The absence must read as - "allowed", or upgrading the node silently closes every group.""" - session = _session(tmp_path, "member-1", member_upload=True, - operator="the-operator") - del session._ctx["member_upload"] - _upload(session) - - assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" - - -# ── Who may change it ─────────────────────────────────────────────────────── - -async def test_changing_it_needs_a_signature(tmp_path): - """ - The request only ever produces a challenge. Nothing is applied until a - signature over the transcript verifies — the same path as removing a member. - """ - session = _session(tmp_path, "member-1", member_upload=True, - operator="the-operator") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) - - session._do_member_upload({"allowed": False}) - - assert issued == [(OP_MEMBER_UPLOAD, "off")] - assert session._ctx["member_upload"] is True, "applied before it was signed" - - -async def test_the_subject_names_the_outcome_not_the_operation(tmp_path): - """The operator is shown the subject before signing. "member_upload" tells - them nothing; "off" tells them what they are about to do.""" - session = _session(tmp_path, "op", member_upload=False, operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) - - session._do_member_upload({"allowed": True}) - - assert issued == [(OP_MEMBER_UPLOAD, "on")] - - -async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): - session = _session(tmp_path, "member-1", member_upload=True, - operator="the-operator") - session._has_admin_authority = lambda: False - - session._do_member_upload({"allowed": False}) - - assert [m for m in session.sent if m.get("type") == "error"] - - -# ── Where it is stored ────────────────────────────────────────────────────── - -async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): - roster = Roster(db_path=tmp_path / "roster.db") - await roster.open() - try: - assert await roster.member_upload_allowed("g1") is True, ( - "absent must mean allowed, or an upgrade closes every group") - await roster.set_member_upload("g1", False, set_by="op") - assert await roster.member_upload_allowed("g1") is False - finally: - await roster.close() - - reopened = Roster(db_path=tmp_path / "roster.db") - await reopened.open() - try: - assert await reopened.member_upload_allowed("g1") is False - assert await reopened.member_upload_allowed("g2") is True, ( - "one group's setting must not answer for another") - finally: - await reopened.close() diff --git a/packages/meshbay-node/tests/test_node_status.py b/packages/meshbay-node/tests/test_node_status.py index b56eb6e..091b1db 100644 --- a/packages/meshbay-node/tests/test_node_status.py +++ b/packages/meshbay-node/tests/test_node_status.py @@ -255,7 +255,7 @@ async def test_add_root_creates_directory_and_returns_info(tmp_path): from meshbay_node.config import NodeConfig, GroupConfig, RootSpec cfg = GroupConfig(id=GROUP, name="test", roots=[ - RootSpec(path=str(shared), name="shared", kind="generic", upload=True), + RootSpec(path=str(shared), name="shared", kind="generic", writable=True), ]) conf = tmp_path / "node.toml" @@ -295,7 +295,7 @@ async def test_remove_root_requires_at_least_one_remaining(tmp_path): from meshbay_node.config import GroupConfig, RootSpec, NodeConfig cfg = GroupConfig(id=GROUP, name="test", roots=[ - RootSpec(path=str(shared), name="shared", kind="generic", upload=True), + RootSpec(path=str(shared), name="shared", kind="generic", writable=True), ]) node_cfg = NodeConfig.__new__(NodeConfig) node_cfg.groups = [cfg] @@ -314,16 +314,22 @@ async def test_remove_root_requires_at_least_one_remaining(tmp_path): await ops.remove_root(state, GROUP, "shared") -async def test_remove_root_refuses_upload_root(tmp_path): - d1 = tmp_path / "uploads" +async def test_removing_a_writable_root_is_allowed(tmp_path): + """ + It used to be refused: with one designated upload root, removing it left + the group with nowhere to put an upload and no way to say so. Several roots + can be writable now, and a group with none is a valid read-only group — so + the refusal would be protecting a state that is no longer special. + """ + d1 = tmp_path / "incoming" d2 = tmp_path / "shared" d1.mkdir() d2.mkdir() from meshbay_node.config import GroupConfig, RootSpec, NodeConfig cfg = GroupConfig(id=GROUP, name="test", roots=[ - RootSpec(path=str(d1), name="uploads", kind="generic", upload=True), - RootSpec(path=str(d2), name="shared", kind="generic", upload=False), + RootSpec(path=str(d1), name="incoming", kind="generic", writable=True), + RootSpec(path=str(d2), name="shared", kind="generic", writable=False), ]) node_cfg = NodeConfig.__new__(NodeConfig) node_cfg.groups = [cfg] @@ -331,7 +337,7 @@ async def test_remove_root_refuses_upload_root(tmp_path): conf = tmp_path / "node.toml" conf.write_text( f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' - f' [[groups.roots]]\n path = "{d1}"\n name = "uploads"\n upload = true\n\n' + f' [[groups.roots]]\n path = "{d1}"\n name = "incoming"\n writable = true\n\n' f' [[groups.roots]]\n path = "{d2}"\n name = "shared"\n') roots = RootSet.build([asdict(r) for r in cfg.roots]) index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) @@ -340,8 +346,97 @@ async def test_remove_root_refuses_upload_root(tmp_path): "config_path": str(conf), "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}}, } - with pytest.raises(ops.OpError, match="upload root"): - await ops.remove_root(state, GROUP, "uploads") + result = await ops.remove_root(state, GROUP, "incoming") + assert result["status"] == "removed" + assert [r["name"] for r in result["roots"]] == ["shared"] + assert conf.read_text().count("[[groups.roots]]") == 1 + + +async def test_update_root_rewrites_the_flags_in_node_toml(tmp_path): + """ + The flags live in the operator's config file, so they survive a restart — + and the file is hand-written and full of comments, so the change is a line + edit rather than a round trip through a TOML writer that would discard + every one of them. + """ + d1 = tmp_path / "media" + d1.mkdir() + + from meshbay_node.config import GroupConfig, RootSpec, NodeConfig + cfg = GroupConfig(id=GROUP, name="test", roots=[ + RootSpec(path=str(d1), name="media", kind="generic", writable=False), + ]) + node_cfg = NodeConfig.__new__(NodeConfig) + node_cfg.groups = [cfg] + + conf = tmp_path / "node.toml" + conf.write_text( + f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' + f' [[groups.roots]]\n' + f' # the operator explained this one to themselves\n' + f' path = "{d1}"\n name = "media"\n') + roots = RootSet.build([asdict(r) for r in cfg.roots]) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + state = { + "config": node_cfg, + "config_path": str(conf), + "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}}, + } + + result = await ops.update_root(state, GROUP, "media", + writable=True, removable=True) + assert result["status"] == "updated" + text = conf.read_text() + assert "writable = true" in text + assert "removable = true" in text + assert "the operator explained this one to themselves" in text, ( + "the config file was rewritten instead of edited") + + # And the live root set agrees immediately, without waiting for a reload: + # the loopback API reads it, and an operator who toggles a switch and sees + # it snap back assumes the change did not take. + assert roots.roots[0].writable is True + assert roots.roots[0].removable is True + + # A second call that changes nothing must not append a duplicate line. + await ops.update_root(state, GROUP, "media", writable=True, removable=True) + assert conf.read_text().count("writable =") == 1 + + +async def test_update_root_replaces_a_legacy_upload_line(tmp_path): + """ + A config written before the refactor says `upload = true`. Leaving it in + place next to a new `writable` line would give the file two answers, and + `RootSet.build` prefers `writable` — so the stale one would sit there + contradicting the running node for as long as anyone read it. + """ + d1 = tmp_path / "media" + d1.mkdir() + + from meshbay_node.config import GroupConfig, RootSpec, NodeConfig + cfg = GroupConfig(id=GROUP, name="test", roots=[ + RootSpec(path=str(d1), name="media", kind="generic", writable=True), + ]) + node_cfg = NodeConfig.__new__(NodeConfig) + node_cfg.groups = [cfg] + + conf = tmp_path / "node.toml" + conf.write_text( + f'[[groups]]\nid = "{GROUP}"\nname = "test"\n\n' + f' [[groups.roots]]\n path = "{d1}"\n name = "media"\n' + f' upload = true\n') + roots = RootSet.build([asdict(r) for r in cfg.roots]) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + state = { + "config": node_cfg, + "config_path": str(conf), + "groups_ctx": {GROUP: {"index": index, "roots": roots, "gek": b"\x01" * 32}}, + } + + await ops.update_root(state, GROUP, "media", writable=False) + text = conf.read_text() + assert "upload = true" not in text + assert "writable = false" in text async def test_remove_root_succeeds_with_two_roots(tmp_path): @@ -352,8 +447,8 @@ async def test_remove_root_succeeds_with_two_roots(tmp_path): from meshbay_node.config import GroupConfig, RootSpec, NodeConfig cfg = GroupConfig(id=GROUP, name="test", roots=[ - RootSpec(path=str(d1), name="dir1", kind="generic", upload=True), - RootSpec(path=str(d2), name="dir2", kind="generic", upload=False), + RootSpec(path=str(d1), name="dir1", kind="generic", writable=True), + RootSpec(path=str(d2), name="dir2", kind="generic", writable=False), ]) node_cfg = NodeConfig.__new__(NodeConfig) node_cfg.groups = [cfg] diff --git a/packages/meshbay-node/tests/test_ops.py b/packages/meshbay-node/tests/test_ops.py index 92e32bf..b3f0378 100644 --- a/packages/meshbay-node/tests/test_ops.py +++ b/packages/meshbay-node/tests/test_ops.py @@ -12,11 +12,13 @@ call them. import asyncio import inspect from pathlib import Path +from types import SimpleNamespace import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_node import ops from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roots import RootSet from meshbay_node.transport.quic_server import Denylist from conftest import one_root @@ -64,8 +66,8 @@ def test_the_http_adapter_adds_no_logic(): # Every endpoint that performs an operation routes through _op(...). for endpoint in ("operator_pair", "create_invite", "revoke_member", "unpin_member", "init_gek", "attach_group", "delete_file", - "add_root", "remove_root", "set_member_upload", - "reload_config"): + "add_root", "remove_root", "update_root", + "eject_root", "plug_root", "reload_config"): start = source.index(f"async def {endpoint}(") body = source[start:start + 700] assert "_op(" in body.split("\n\n")[0] + body, ( @@ -181,25 +183,103 @@ async def test_an_unhosted_group_offers_what_it_does_host(tmp_path): assert exc.value.extra.get("available") -# ── Upload policy (set_member_upload) ─────────────────────────────────────── +# ── Upload policy (per-root writable) ─────────────────────────────────────── -async def test_set_member_upload_toggles_and_persists(tmp_path): +async def test_the_group_wide_upload_switch_is_gone(tmp_path): + """ + `set_member_upload` was the whole of the old policy, and it is deliberately + not here any more — RO/RW on the root replaced it. A wrapper kept "for + compatibility" would be a second way to decide who writes to the operator's + disk, and two answers to that question is how C1 and C6 both happened. + """ + assert not hasattr(ops, "set_member_upload") + from meshbay_node.roster import Roster + assert not hasattr(Roster, "set_member_upload") + assert not hasattr(Roster, "member_upload_allowed") + + +async def test_eject_and_plug_persist_through_the_roster(tmp_path): + """ + The state has to outlive the process: an operator ejects a drive, unplugs + it, and restarts the node — and the rescan that follows must not read the + empty mount point as an erased library. + """ from meshbay_node.roster import Roster state = _state(tmp_path) + usb = tmp_path / "USB" + usb.mkdir() + state["groups_ctx"]["g" * 32]["roots"] = RootSet.build( + [{"path": str(usb), "removable": True, "writable": True}]) + state["config"] = SimpleNamespace( + groups=[SimpleNamespace(id="g" * 32, roots=[])]) roster = Roster(db_path=tmp_path / "roster.db") await roster.open() state["roster"] = roster state["node_user_id"] = "operator" + try: + out = await ops.eject_root(state, "g" * 32, "USB") + assert out["status"] == "ejected" + assert await roster.ejected_roots("g" * 32) == {"usb"} + assert out["roots"][0]["ejected"] is True + assert out["roots"][0]["available"] is False + + out = await ops.plug_root(state, "g" * 32, "USB") + assert out["status"] == "plugged" + assert await roster.ejected_roots("g" * 32) == set() + finally: + await roster.close() - out = await ops.set_member_upload(state, "g" * 32, True) - assert out["allowed"] is True - assert state["groups_ctx"]["g" * 32]["member_upload"] is True +async def test_a_root_that_is_not_removable_cannot_be_ejected(tmp_path): + """ + Eject means "I am about to unplug this". On a directory that is not on a + removable device it would hide a library with no way for the safety net to + notice anything happened, and nothing to plug back in. + """ + from meshbay_node.roster import Roster + state = _state(tmp_path) + fixed = tmp_path / "Fixed" + fixed.mkdir() + state["groups_ctx"]["g" * 32]["roots"] = RootSet.build( + [{"path": str(fixed), "writable": True}]) + state["config"] = SimpleNamespace( + groups=[SimpleNamespace(id="g" * 32, roots=[])]) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + state["roster"] = roster + try: + with pytest.raises(ops.OpError, match="removable"): + await ops.eject_root(state, "g" * 32, "Fixed") + finally: + await roster.close() - out2 = await ops.set_member_upload(state, "g" * 32, False) - assert out2["allowed"] is False - assert state["groups_ctx"]["g" * 32]["member_upload"] is False +async def test_plugging_a_drive_that_is_not_there_is_refused(tmp_path): + """ + Clearing the flag while the device is still absent would restart the + watchdog on a missing path and hand the next reconcile an empty directory — + the deletion storm the eject was there to prevent, produced by the recovery. + """ + from meshbay_node.roster import Roster + state = _state(tmp_path) + usb = tmp_path / "USB" + usb.mkdir() + roots = RootSet.build([{"path": str(usb), "removable": True}]) + roots.roots[0].ejected = True + roots.roots[0].available = False + state["groups_ctx"]["g" * 32]["roots"] = roots + state["config"] = SimpleNamespace( + groups=[SimpleNamespace(id="g" * 32, roots=[])]) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + state["roster"] = roster + usb.rmdir() + try: + with pytest.raises(ops.OpError, match="device connected"): + await ops.plug_root(state, "g" * 32, "USB") + assert roots.roots[0].ejected is True + finally: + await roster.close() # ── Reload ────────────────────────────────────────────────────────────────── diff --git a/packages/meshbay-node/tests/test_root_availability.py b/packages/meshbay-node/tests/test_root_availability.py index 0201c1f..d514dee 100644 --- a/packages/meshbay-node/tests/test_root_availability.py +++ b/packages/meshbay-node/tests/test_root_availability.py @@ -26,9 +26,12 @@ from meshbay_node.roots import RootSet pytestmark = pytest.mark.asyncio -def _roots(*paths: Path) -> RootSet: +def _roots(*paths: Path, removable: bool = False) -> RootSet: specs = [{"path": str(p)} for p in paths] - specs[0]["upload"] = True + specs[0]["writable"] = True + if removable: + for spec in specs: + spec["removable"] = True return RootSet.build(specs) @@ -117,7 +120,9 @@ 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, "upload": True}] + {"name": "Films", "kind": "generic", "available": True, + "writable": True, "removable": False, "ejected": False, + "upload": True}] (films / "a.mkv").unlink() films.rmdir() diff --git a/packages/meshbay-node/tests/test_root_eject.py b/packages/meshbay-node/tests/test_root_eject.py new file mode 100644 index 0000000..0ec36a4 --- /dev/null +++ b/packages/meshbay-node/tests/test_root_eject.py @@ -0,0 +1,268 @@ +""" +Safe eject, and the surprise unplug it exists to survive. + +`test_root_availability.py` pins the freeze: a root that goes away keeps its +entries. This pins the half the operator drives — telling the node the drive is +about to leave, and telling it the drive is back. + +The distinction that makes any of this work is that `ejected` and `is_live()` +are separate answers. Between clicking Eject and physically unplugging, the +directory is still readable; a design that recomputed availability from the +filesystem alone would flip the root straight back to available and start +serving files from a disk somebody has their hand on. + +The other property here is that the flag is *persisted*. It reached the roster +in the first implementation and was never read back, so a restart — which is +exactly what an operator does after noticing a drive fell off — silently undid +the eject, and the next scan read an empty mount point as an erased library. +""" + +import asyncio +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_node.indexer.indexer import DirectoryIndexer +from meshbay_node.roots import RootSet +from meshbay_node.roster import Roster + +pytestmark = pytest.mark.asyncio + + +def _roots(*paths: Path, removable: bool = True) -> RootSet: + return RootSet.build([ + {"path": str(p), "removable": removable} for p in paths]) + + +async def _indexer(roots: RootSet, **kw) -> DirectoryIndexer: + idx = DirectoryIndexer(roots=roots, group_id="g" * 32, + sk_node=Ed25519PrivateKey.generate(), gek=None, **kw) + await idx.initial_scan() + return idx + + +def _names(idx: DirectoryIndexer) -> set[str]: + return {e.name for e in idx.index.entries} + + +# ── The two states are not the same question ───────────────────────────────── + +async def test_ejecting_hides_a_root_that_is_still_readable(tmp_path): + """ + The whole point of an eject button: the operator says the drive is leaving + *before* it leaves. The directory is still there and still readable at this + moment, so anything deriving availability from the filesystem would refuse + to believe it. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + idx.eject_root("Films") + + assert films.is_dir(), "the drive has not been unplugged yet" + assert roots.roots[0].is_live() is True + assert roots.roots[0].available is False + assert idx.index.roots[0]["ejected"] is True + assert idx.index.roots[0]["available"] is False + + +async def test_an_eject_freezes_entries_rather_than_dropping_them(tmp_path): + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + (films / "b.mkv").write_bytes(b"b") + + idx = await _indexer(_roots(films)) + idx.eject_root("Films") + + assert _names(idx) == {"a.mkv", "b.mkv"}, "eject deleted entries" + + +async def test_reconciling_does_not_un_eject_a_root(tmp_path): + """ + The backstop runs every minute regardless. An ejected root whose directory + is still readable must stay ejected, or the operator's eject lasts until + the next tick. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + idx.eject_root("Films") + await idx.reconcile() + + assert roots.roots[0].ejected is True + assert roots.roots[0].available is False + + +async def test_plugging_back_relists_the_files(tmp_path): + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + idx.eject_root("Films") + await idx.plug_root("Films") + + assert roots.roots[0].ejected is False + assert roots.roots[0].available is True + assert _names(idx) == {"a.mkv"} + + +async def test_what_changed_while_unplugged_is_picked_up_on_plug(tmp_path): + """ + A drive people take away comes back different. The plug pass has to see + that, or the index describes a library that no longer exists on the disk + the node is about to serve from. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + idx.eject_root("Films") + + (films / "a.mkv").unlink() + (films / "c.mkv").write_bytes(b"c") + + await idx.plug_root("Films") + assert _names(idx) == {"c.mkv"} + + +# ── The surprise unplug ────────────────────────────────────────────────────── + +async def test_a_removable_root_that_vanishes_is_auto_ejected(tmp_path): + """ + Nobody clicks Eject when they are in a hurry. A removable root whose path + disappears is treated as ejected rather than merely unavailable, so it does + not silently come back the moment the same mount point is readable again — + which on a machine with automount is any other drive, or an empty stub. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films) + idx = await _indexer(roots) + + (films / "a.mkv").unlink() + films.rmdir() + await idx.reconcile() + + assert roots.roots[0].ejected is True + assert _names(idx) == {"a.mkv"}, "the library was treated as erased" + + +async def test_a_non_removable_root_is_not_auto_ejected(tmp_path): + """ + The counter-property. Auto-eject requires the operator to have said the + device is removable; an ordinary directory that briefly fails to stat must + keep the old behaviour and come back on its own. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + roots = _roots(films, removable=False) + idx = await _indexer(roots) + + (films / "a.mkv").unlink() + films.rmdir() + await idx.reconcile() + assert roots.roots[0].ejected is False + assert roots.roots[0].available is False + + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + await idx.reconcile() + assert roots.roots[0].available is True + + +async def test_an_auto_eject_is_reported_so_it_can_be_persisted(tmp_path): + """ + The flag has to outlive the process. The first version of this set it in + memory only, so restarting the node — which is what an operator does after + noticing a drive fell off — cleared it, and the scan that followed read the + empty mount point as a deletion of the whole library. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + seen: list[tuple[str, bool]] = [] + + async def record(name: str, ejected: bool) -> None: + seen.append((name, ejected)) + + roots = _roots(films) + idx = await _indexer(roots, on_root_ejected=record) + + (films / "a.mkv").unlink() + films.rmdir() + await idx.reconcile() + + assert seen == [("Films", True)] + + # And only once, however many times the backstop runs afterwards. + await idx.reconcile() + await idx.reconcile() + assert seen == [("Films", True)] + + +# ── Restoring the flag ─────────────────────────────────────────────────────── + +async def test_a_root_built_as_ejected_starts_unavailable(tmp_path): + """ + What the daemon does with what the roster remembers. `available` must not + be left at its default `True` here, or the group serves a drive that is not + there for as long as it takes the first reconcile to run. + """ + films = tmp_path / "Films" + films.mkdir() + roots = RootSet.build([{"path": str(films), "removable": True, + "ejected": True}]) + assert roots.roots[0].ejected is True + assert roots.roots[0].available is False + + +async def test_the_roster_round_trips_the_ejected_set(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.ejected_roots("g1") == set() + + await roster.set_root_ejected("g1", "Films", True, set_by="op") + await roster.set_root_ejected("g1", "Music", False, set_by="op") + assert await roster.ejected_roots("g1") == {"films"} + + # Another group's drives are its own. + assert await roster.ejected_roots("g2") == set() + + await roster.set_root_ejected("g1", "Films", False, set_by="op") + assert await roster.ejected_roots("g1") == set() + finally: + await roster.close() + + +async def test_the_ejected_key_is_case_folded(tmp_path): + """ + Root names are compared without regard to case everywhere else, and a key + that did not fold would let `Films` and `films` disagree about the same + drive — on Windows and macOS, the same directory. + """ + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_root_ejected("g1", "FILMS", True, set_by="op") + assert await roster.ejected_roots("g1") == {"films"} + assert Roster.root_ejected_key("Films") == Roster.root_ejected_key("FILMS") + finally: + await roster.close() diff --git a/packages/meshbay-node/tests/test_root_writable_policy.py b/packages/meshbay-node/tests/test_root_writable_policy.py new file mode 100644 index 0000000..da95032 --- /dev/null +++ b/packages/meshbay-node/tests/test_root_writable_policy.py @@ -0,0 +1,203 @@ +""" +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: + +* 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. + A member with an old tab open, or one speaking MNP directly, gets the same + answer. That half is pinned in `test_security_regressions.py`, next to the + overwrite properties it belongs with; +* the setting is changed by a **signed** operator instruction, or it is a + suggestion any member can undo; +* it is stored on the **node**, never the hub. A hub that could decide who + writes to the operator's disk would have authority over the node. + +And one that is new: the *old* message must no longer be able to change +anything. A deprecated instruction that still works is not deprecated, and this +one would reopen uploads group-wide. +""" + +import base64 +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.adminop import OP_ROOT_UPDATE, OP_ROOT_EJECT, OP_ROOT_PLUG +from meshbay_common.protocol import MNP +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roots import RootSet +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, + writable: bool = True, + operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": RootSet.build([{"path": str(shared_root), "writable": writable}]), + "index": index, + "sk_node": index.sk_node, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = "g" * 32 + session._user_id = user_id + session._pk_user = "" + session._uploads = {} + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _upload(session, filename="clip.mp4", body=b"bytes"): + session._do_file_upload({ + "filename": filename, "root": "shared", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(body).decode(), + }) + + +def _uploads_dir(session) -> Path: + return session._ctx["roots"].roots[0].path / "uploads" + + +# ── The door, not the button ───────────────────────────────────────────────── + +async def test_a_member_cannot_upload_to_a_read_only_root(tmp_path): + session = _session(tmp_path, "member-1", writable=False) + _upload(session) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_read_only" + assert not _uploads_dir(session).exists() + + +async def test_members_upload_normally_to_a_writable_root(tmp_path): + session = _session(tmp_path, "member-1", writable=True) + _upload(session) + + assert not [m for m in session.sent if m.get("type") == "error"] + assert (_uploads_dir(session) / "clip.mp4").read_bytes() == b"bytes" + + +async def test_read_only_binds_the_operator_too(tmp_path): + """ + The old model exempted the operator, because the switch was about *members*. + RO is about the directory: a published library is read-only for everyone, and + an exception for admin authority is how a rule turns into a default. + """ + session = _session(tmp_path, "the-operator", writable=False, + operator="the-operator") + session._is_node_admin = lambda: True + _upload(session) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_read_only" + + +# ── Signed, or it is a suggestion ──────────────────────────────────────────── + +def _capture_challenges(session) -> list[tuple[str, str]]: + issued: list[tuple[str, str]] = [] + + def issue(op, subject, **kw): + issued.append((op, subject)) + + session._issue_admin_challenge = issue + session._has_admin_authority = lambda: True + return issued + + +async def test_changing_a_roots_flags_needs_a_signature(tmp_path): + """The flags are not applied by the request — only by the signed response.""" + session = _session(tmp_path, "the-operator", operator="the-operator") + issued = _capture_challenges(session) + + session._do_root_update({"group_id": "g" * 32, "root_name": "shared", + "writable": False}) + + assert [op for op, _ in issued] == [OP_ROOT_UPDATE] + assert session._ctx["roots"].roots[0].writable is True, ( + "applied before it was signed") + + +async def test_the_subject_names_the_outcome_not_the_operation(tmp_path): + """ + The operator is shown the subject before signing, so it has to say what will + be true afterwards. "shared" alone would have them authorize a change they + cannot see the direction of. + """ + session = _session(tmp_path, "op", operator="op") + issued = _capture_challenges(session) + + session._do_root_update({"group_id": "g" * 32, "root_name": "shared", + "writable": True, "removable": True}) + + assert issued == [(OP_ROOT_UPDATE, "shared:rw=on,rem=on")] + + +async def test_eject_and_plug_are_signed_too(tmp_path): + """ + Hiding a group's whole library from every member is not a lesser act than + changing a flag. An unsigned one would let any member black out a group. + """ + session = _session(tmp_path, "op", operator="op") + issued = _capture_challenges(session) + + session._do_root_eject({"group_id": "g" * 32, "root_name": "shared"}) + session._do_root_plug({"group_id": "g" * 32, "root_name": "shared"}) + + assert issued == [(OP_ROOT_EJECT, "shared"), (OP_ROOT_PLUG, "shared")] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + """ + An unpaired node has no key to check a signature against, so the challenge + is never issued rather than issued and then unverifiable. + """ + session = _session(tmp_path, "member-1") + issued = _capture_challenges(session) + session._has_admin_authority = lambda: False + + session._do_root_update({"group_id": "g" * 32, "root_name": "shared", + "writable": True}) + + assert issued == [] + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── The deprecated message must not still work ─────────────────────────────── + +async def test_the_old_member_upload_message_changes_nothing(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. + """ + session = _session(tmp_path, "member-1", writable=False) + session._has_admin_authority = lambda: True + issued = _capture_challenges(session) + + session._do_member_upload({"allowed": True}) + + assert issued == [], "a deprecated 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 + + # And the door is still shut. + _upload(session) + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_read_only" diff --git a/packages/meshbay-node/tests/test_roots.py b/packages/meshbay-node/tests/test_roots.py index 1beb220..505091b 100644 --- a/packages/meshbay-node/tests/test_roots.py +++ b/packages/meshbay-node/tests/test_roots.py @@ -108,31 +108,61 @@ def test_a_sibling_with_a_shared_prefix_is_fine(tmp_path): assert roots.names == ["Media", "Media2"] -# ── Uploads ────────────────────────────────────────────────────────────────── +# ── Writable roots ─────────────────────────────────────────────────────────── -def test_a_single_root_receives_uploads_without_being_asked(tmp_path): +def test_a_root_is_read_only_unless_it_says_otherwise(tmp_path): + """ + The default is the safe one. An operator who shares a directory has not + thereby agreed to let anyone write into it, and the version of this that + guessed — one root, so it must be the upload target — meant adding a + second directory silently changed what the first one was. + """ (tmp_path / "Media").mkdir() roots = RootSet.build([_spec(tmp_path / "Media")]) - assert roots.upload_root is roots.roots[0] + assert roots.roots[0].writable is False + assert roots.writable_roots == [] + + +def test_several_roots_can_be_writable_at_once(tmp_path): + (tmp_path / "A").mkdir() + (tmp_path / "B").mkdir() + (tmp_path / "C").mkdir() + roots = RootSet.build([_spec(tmp_path / "A", writable=True), + _spec(tmp_path / "B"), + _spec(tmp_path / "C", writable=True)]) + assert [r.name for r in roots.writable_roots] == ["A", "C"] -def test_several_roots_and_no_designation_means_no_uploads(tmp_path): +def test_a_fully_read_only_group_is_valid(tmp_path): """ - Refused, never guessed: picking one would send a member's file to a disk the - operator did not intend, and that is discovered weeks later. + A group that only publishes is the point of the read-only model, not a + misconfiguration — build must not refuse it, and nothing downstream may + promote a root to writable to have somewhere to put an upload. """ (tmp_path / "A").mkdir() (tmp_path / "B").mkdir() roots = RootSet.build([_spec(tmp_path / "A"), _spec(tmp_path / "B")]) - assert roots.upload_root is None + assert roots.writable_roots == [] + assert len(roots) == 2 -def test_two_upload_roots_are_refused(tmp_path): - (tmp_path / "A").mkdir() - (tmp_path / "B").mkdir() - with pytest.raises(RootError, match="exactly one"): - RootSet.build([_spec(tmp_path / "A", upload=True), - _spec(tmp_path / "B", upload=True)]) +def test_the_old_upload_flag_still_reads_as_writable(tmp_path): + """A node.toml written before this refactor must not change meaning.""" + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media", upload=True)]) + assert roots.roots[0].writable is True + assert roots.describe()[0]["writable"] is True + + +def test_writable_wins_over_a_leftover_upload_flag(tmp_path): + """ + A config carrying both is one a migration touched. `writable` is the field + the operator's tooling writes now, so it is the one that decides — reading + the legacy field there would undo the migration on the next load. + """ + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media", upload=True, writable=False)]) + assert roots.roots[0].writable is False # ── Resolution ─────────────────────────────────────────────────────────────── @@ -236,18 +266,35 @@ def test_availability_follows_the_directory(tmp_path): def test_describe_reports_what_a_member_needs(tmp_path): (tmp_path / "Media").mkdir() (tmp_path / "Music").mkdir() - roots = RootSet.build([_spec(tmp_path / "Media", upload=True), - _spec(tmp_path / "Music", kind="audio")]) + roots = RootSet.build([_spec(tmp_path / "Media", writable=True), + _spec(tmp_path / "Music", kind="audio", + removable=True)]) described = roots.describe() assert described == [ - {"name": "Media", "kind": "generic", "available": True, "upload": True}, - {"name": "Music", "kind": "audio", "available": True, "upload": False}, + {"name": "Media", "kind": "generic", "available": True, + "writable": True, "removable": False, "ejected": False, + "upload": True}, + {"name": "Music", "kind": "audio", "available": True, + "writable": False, "removable": True, "ejected": False, + "upload": 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): + """ + `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. + """ + (tmp_path / "Media").mkdir() + roots = RootSet.build([_spec(tmp_path / "Media", writable=True)]) + described = roots.describe()[0] + assert described["upload"] == described["writable"] is True + + # ── SAFE_UPLOAD_NAME ──────────────────────────────────────────────────────── def test_safe_name_accepts_unicode_letters(): diff --git a/packages/meshbay-node/tests/test_scan_settings_policy.py b/packages/meshbay-node/tests/test_scan_settings_policy.py index 719b988..94f4421 100644 --- a/packages/meshbay-node/tests/test_scan_settings_policy.py +++ b/packages/meshbay-node/tests/test_scan_settings_policy.py @@ -2,7 +2,7 @@ The operator can tune how often the indexer's reconciliation backstop runs, and how long it waits after a file's last write before hashing it. -Same shape as test_apps_enabled_policy.py / test_member_upload_policy.py: +Same shape as test_apps_enabled_policy.py / test_root_writable_policy.py: changed by a signed operator instruction, stored on the node rather than the hub. Unlike those two, there is also a *live* DirectoryIndexer object to update — see test_set_scan_settings_updates_the_live_indexer below. diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 7f71da5..9db8ac1 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -18,6 +18,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common.protocol import IndexEntry from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roots import RootSet from conftest import one_root from meshbay_node.transport.webrtc_server import WebRTCPeerSession @@ -132,14 +133,14 @@ def test_the_node_never_generates_a_name_it_would_refuse(tmp_path): def _uploads_dir(session) -> Path: """ - Where this session's uploads land: uploads/ inside the group's upload root. + Where this session's uploads land: uploads/ inside its first writable root. Asked of the root set rather than assembled by hand, so a test cannot pass while agreeing with a wrong answer the code also produced. """ - root = session._ctx["roots"].upload_root - assert root is not None, "the fixture must designate an upload root" - return root.path / "uploads" + writable = session._ctx["roots"].writable_roots + assert writable, "the fixture must give the group a writable root" + return writable[0].path / "uploads" def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession: @@ -227,14 +228,17 @@ def test_dir_create_cannot_escape_the_shared_root(tmp_path, bad): def test_upload_ignores_any_directory_the_client_asks_for(tmp_path): """ - Uploads land in uploads/, chosen by the node. A client that names somewhere - else — or nowhere at all — changes nothing, so the traversal surface that a - client-chosen destination would open does not exist on this path. + The destination inside a root is the node's decision, and stays so. + + A client now names the *root* it is uploading into — it has to, once a group + can have several writable ones — but that is a name looked up in the root + table, never a path. Everything below the root is still chosen here, so the + traversal surface a client-chosen destination would open does not exist. """ session = _session(tmp_path, "user-1") session._do_file_upload({ - "filename": "note.txt", "dir": "../../etc", + "filename": "note.txt", "dir": "../../etc", "path": "/etc", "chunk_index": 0, "total_chunks": 1, "data": base64.b64encode(b"x").decode(), }) @@ -243,6 +247,131 @@ def test_upload_ignores_any_directory_the_client_asks_for(tmp_path): assert not (tmp_path / "etc").exists() +@pytest.mark.parametrize("named_root", [ + "../../etc", "/etc", "shared/../..", "Shared/uploads", "nope", +]) +def test_a_root_name_is_looked_up_never_joined(tmp_path, named_root): + """ + The name the client sends is matched against the group's root table and + refused when it matches nothing. A version that joined it to a path — or + that quietly fell back to the first writable root — would turn "which + directory" into either a traversal or a file on a disk the operator did + not intend, and the second is discovered weeks later. + """ + session = _session(tmp_path, "user-1") + before = set(tmp_path.rglob("*")) + + session._do_file_upload({ + "filename": "note.txt", "root": named_root, + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "no_such_root", named_root + assert set(tmp_path.rglob("*")) == before, f"wrote something via {named_root!r}" + + +def test_an_upload_goes_to_the_root_it_names(tmp_path): + """ + With two writable roots there is no defensible default, and the client is + the only party that knows which directory the person is looking at. The + node picking one meant a file uploaded from a folder on screen landed in a + different one — the same "uploads went somewhere else" the single upload + root was never allowed to guess about. + """ + media = tmp_path / "Media" + incoming = tmp_path / "Incoming" + media.mkdir() + incoming.mkdir() + session = _session(tmp_path, "user-1") + session._ctx["roots"] = RootSet.build([ + {"path": str(media), "writable": True}, + {"path": str(incoming), "writable": True}, + ]) + + session._do_file_upload({ + "filename": "note.txt", "root": "Incoming", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + assert (incoming / "uploads" / "note.txt").read_bytes() == b"x" + assert not (media / "uploads").exists(), "it went to the first root instead" + + +def test_a_read_only_root_refuses_an_upload(tmp_path): + """ + RO is the mechanism now, not a hidden button. It binds the operator too: + "read-only for everyone" is what makes a published library one, and an + exception for whoever happens to hold admin authority is the sort of + carve-out that later reads as the rule. + """ + published = tmp_path / "Published" + published.mkdir() + session = _session(tmp_path, "user-1") + session._ctx["roots"] = RootSet.build([{"path": str(published)}]) + session._is_node_admin = lambda: True + + session._do_file_upload({ + "filename": "note.txt", "root": "Published", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_read_only" + assert not (published / "uploads").exists() + + +def test_a_fully_read_only_group_refuses_an_unaddressed_upload(tmp_path): + """ + An MNP 1.0 client names no root, so the node falls back to the first + writable one. There isn't one here, and the fallback must refuse rather + than write into whatever root happens to come first. + """ + published = tmp_path / "Published" + published.mkdir() + session = _session(tmp_path, "user-1") + session._ctx["roots"] = RootSet.build([{"path": str(published)}]) + + session._do_file_upload({ + "filename": "note.txt", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "no_writable_root" + assert not (published / "uploads").exists() + + +def test_an_ejected_root_refuses_an_upload(tmp_path): + """ + Writing to a drive somebody has their hand on is the thing eject exists to + stop. `writable` is still true — that is configuration — so availability + has to be checked separately, which is what an earlier version conflated. + """ + usb = tmp_path / "USB" + usb.mkdir() + session = _session(tmp_path, "user-1") + roots = RootSet.build([{"path": str(usb), "writable": True, + "removable": True}]) + roots.roots[0].ejected = True + roots.roots[0].available = False + session._ctx["roots"] = roots + + session._do_file_upload({ + "filename": "note.txt", "root": "USB", + "chunk_index": 0, "total_chunks": 1, + "data": base64.b64encode(b"x").decode(), + }) + + refusal = [m for m in session.sent if m.get("type") == "error"] + assert refusal and refusal[0].get("code") == "root_unavailable" + assert not (usb / "uploads").exists() + + def test_two_members_can_send_the_same_filename(tmp_path): """ One shared uploads/ means collisions are ordinary — every camera produces |