aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md21
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md38
-rw-r--r--docs/USERGUIDE.md5
-rw-r--r--packages/meshbay-client/src/main.js11
-rw-r--r--packages/meshbay-client/src/transcripts.js17
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py9
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py21
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py6
-rw-r--r--packages/meshbay-common/tests/test_admin_subject_parity.py47
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js129
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-media.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js24
-rw-r--r--packages/meshbay-hub/tests/test_desktop_keyring.py22
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py5
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py30
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py15
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/roots.py12
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py3
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py168
-rw-r--r--packages/meshbay-node/tests/golden/dispatch.json1237
-rw-r--r--packages/meshbay-node/tests/test_admin_challenge_bounds.py45
-rw-r--r--packages/meshbay-node/tests/test_node_status.py58
-rw-r--r--packages/meshbay-node/tests/test_root_writable_policy.py38
-rw-r--r--packages/meshbay-node/tests/test_sharing_is_local_only.py109
39 files changed, 457 insertions, 1729 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index eec354a..e145ed4 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -16,7 +16,7 @@
> them — it names the invariant that holds today, not the incident that produced
> it. §13 is the register of those labels.
>
-> Wire versions at the time of writing: **MNP 5.0** (oldest peer accepted 4.0),
+> Wire versions at the time of writing: **MNP 6.0** (oldest peer accepted 4.0),
> **MHP 0.1**, packages **0.17.0**. The normative source for the wire format is
> `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol
> serves, not its byte layout.
@@ -99,7 +99,7 @@ opens them.
│ └─────────┘
MHP 0.1 │ signalling (SDP/ICE, <1 KB), presence, revocation push MHP
│
- ┌────┴────┐ MNP 5.0 ┌──────────┐
+ ┌────┴────┐ MNP 6.0 ┌──────────┐
│ node │◄──────── WebRTC DataChannel / QUIC ──────────►│ client │
└─────────┘ index, file chunks, streams, chat, admin └──────────┘
holds the files browser SPA or desktop
@@ -1451,8 +1451,8 @@ checks the version its peer declared and **branches on none of it**.
**The floor is not necessarily the current version, and what is added above it is why.**
It is `MNP_MIN_SUPPORTED` in `handshake.py`, and it is the last MAJOR that had to
refuse at the handshake: 3.1–3.4 were added above the 3.0 floor without moving it,
-and 5.0, a MAJOR confined to four signed operations that a peer across the break
-refuses to sign, sits above the 4.0 floor. So a
+and 5.0 and 6.0 — MAJORs confined to a few signed operations, four whose subjects
+changed and then three removed — sit above the 4.0 floor. So a
peer can be reachable and still not do something the current version can, and the
client has to cope with that — **by reading the peer's own answer, never by comparing
version numbers**.
@@ -1541,7 +1541,17 @@ Five consequences, none optional:
- `writable = true` means any group member may upload there.
Several roots may be writable and none need be — a fully read-only group is valid.
-The operator toggles this with a signed op.
+
+**What widens the sharing is decided on the node's own machine.** Adding a root,
+hosting a group over a directory, and switching `writable` or `removable` go through
+the loopback API (in the desktop application, behind a native dialog for anything
+that shares a folder or opens one to writes) or the CLI — never over MNP, since 6.0.
+A signed op proves that the operator's key signed, not that they meant it: in a
+browser that key is driven by code the hub serves (T3), and in the desktop
+application by a renderer that parses content from nodes. Either could otherwise
+have shared any folder on the machine, writable, from anywhere. The operator still
+sees every root and its flags from any browser; removing, ejecting and plugging stay
+signed ops, because they narrow what is shared or restore what already was.
> **There is one answer to "may this member write", and it is the root.** A single
> flag over the group cannot express "this library is published read-only and that
@@ -3802,6 +3812,7 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| **The Create group wizard calls two hooks after an early return** | `CreateGroupWizard` (`create-group-page.js`) returns during node detection, before its `useRef`/`useEffect` for provisioning, so the hook count changes between renders. Preact tolerates a list that grows, and nothing is known to break; `test_hook_ordering.py` checks declaration order, not this. Found while tracing the frozen-fields report, which had another cause (`ask.js`) |
| **A node key is read from the terminal or the desktop client, never a browser** | **Accepted.** `meshbay-node status` on the node's own machine and Node → Overview in the desktop client are the two places the key can be read; the Node page is Electron-only, because `platform.node` resolves to "not available" without the bridge, and no hub route exposes the key. The create-group wizard links it automatically over that same bridge, so the manual paste in **Profile → Link Node** exists for the operator who runs the node from a terminal and the hub from a browser — who has a terminal by definition. Anyone linking a node is already at a shell prompt, so a browser-reachable copy would buy nothing and widen what the hub knows about the node |
| **Listing a group's folders walks every root on the event loop** | `index_sync_message` (`transport/wire.py`) builds its `dirs` field with `list_dirs`, an `rglob("*")` over every root, and nothing sends it off the loop: the WebRTC `index_sync` handler, the daemon's index push and QUIC all call it inline. So each index request from any member is a directory walk of the whole library that every other peer on the node waits behind. `test_disk_io_off_loop.py` never saw it, because it reads the transport's own modules and the walk is one call away in `wire.py`. Found by widening what that test reads, not by a symptom |
+| **A loopback eject or plug reaches open pages late** | `ops.eject_root` and `ops.plug_root` flip the live set and tell nobody; over MNP the broadcast `root_eject_ack` / `root_plug_ack` is what moves every open table. So an eject made from the desktop application or the CLI shows on members' pages only with the next index push — for a plug, the end of its rescan; for an eject, whatever changes next. `ops.update_root` had the same silence and now calls `DirectoryIndexer.publish_roots`; the same call belongs in these two. Found while moving the `writable`/`removable` switches to the loopback door (MNP 6.0) |
| **The transcoded-seek test passes without transcoding** | `test_a_transcoded_video_keeps_accurate_seeking` (`test_stream_seek_audio_alignment.py`) forces the re-encode branch by swapping the module's `BROWSER_INCOMPATIBLE_VIDEO_CODECS`, then checks only that the result has no audio gap. The copy path also leaves no gap on that clip, so pointing the swap at a module the streaming code does not read still passes: the test cannot tell that the branch it is named after never ran. It should assert the re-encode happened (the `re-encoding` log line, or the encoder in the ffmpeg argv). Found by breaking the swap on purpose while moving the streaming code |
---
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index e7fce90..d03b7eb 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -1,6 +1,6 @@
# MeshBay Node Protocol (MNP)
-**Wire version:** `5.0` — `meshbay_common/__init__.py` (`MNP_VERSION`)
+**Wire version:** `6.0` — `meshbay_common/__init__.py` (`MNP_VERSION`)
**Oldest peer accepted:** `4.0` — `handshake.py` (`MNP_MIN_SUPPORTED`)
**Normative implementation:** `meshbay-common` (`protocol.py`, `handshake.py`,
`groupbox.py`, `chatbox.py`, `adminop.py`, `join.py`, `device.py`, `crypto.py`,
@@ -1165,9 +1165,7 @@ broadcast, every connected peer in the group learns the change without reconnect
| `tmdb_override` | `file_id=..,tmdb_id=..,media_type=..` | operator | `tmdb_override_ack{file_id, tmdb_id, media_type}` | yes |
| `tmdb_rematch` | `file_id=..` | operator | `tmdb_rematch_ack{file_id}` | yes |
| `musicbrainz_enabled` | `enabled` | operator | `musicbrainz_enabled_ack{enabled}` | yes |
-| `root_add` | `{path, name, kind, writable, removable}` | operator | `root_add_ack` | no |
| `root_remove` | the root name | operator | `root_remove_ack` | no |
-| `root_update` | `<root>:rw=on\|off,rem=on\|off` | operator | `root_update_ack` | yes |
| `root_eject`, `root_plug` | the root name | operator | `root_eject_ack` / `root_plug_ack` | yes |
| `app_directories` | `<app>:<dir>,<dir>,...` | operator | `app_directories_ack{app, dirs}` | yes |
| `chat_directory` | the path | operator | `chat_directory_ack{path}` | yes |
@@ -1175,14 +1173,28 @@ broadcast, every connected peer in the group learns the change without reconnect
| `search_listed` | `on\|off` | operator | `search_listed_ack{listed}` | yes |
| `transfer_limits` | `d=<n>,u=<n>` | operator | `transfer_limits_ack{limits}` | yes |
| `chat_epoch` | `group_id` | operator | `chat_epoch_ack{epoch}` | yes |
-| `group_attach` | `{name, shared_dir, writable}` | operator | `group_attach_ack` | no |
| `group_detach` | the group name | operator | `group_detach_ack` | no |
**Upload policy is not in this table**, and that is the design: whether a member may
-write is a property of each root (`root_update`), not a switch over the group. A single
+write is a property of each root (its `writable` flag), not a switch over the group. A single
group-wide flag cannot express "this library is published read-only and that folder is a
drop box", which is the ordinary arrangement.
+**Nothing in this table widens what the node shares**, and that is the design too.
+Adding a directory to a group, hosting a new group over a directory, and switching
+a root's `writable` or `removable` flag are done on the node's own machine — the
+desktop application over the loopback API, which asks in a native dialog before it
+shares a folder or opens one to writes, or the CLI — and never over MNP. Until 6.0
+they were the signed ops `root_add`, `group_attach` and `root_update`. A signature
+proves that the operator's key signed, not that the operator meant it: in a browser
+that key is driven by code the hub serves (T3), and in the desktop application by a
+renderer that parses content from nodes. Either could have shared any folder on the
+operator's machine, writable, from anywhere. What is left here narrows (`root_remove`,
+`root_eject`) or restores what the operator already shared (`root_plug`). The
+operator still sees the table from any browser: it rides in `index_sync` and
+`index_delta`, which is also how a change made on the node's machine reaches every
+open page.
+
`app_directories` is the **only** way an application's folders are set: one message
for every application, keyed by the app's own registry name, so adding an application
adds no message type, no signed op and no handler.
@@ -2128,7 +2140,6 @@ it back (§3.5).
| `chat_directory` / `_ack` | C→N / N⇒C | signed | where chat attachments are written |
| `chat_link_preview` / `_ack` | C→N / N⇒C | signed | whether the node unfurls posted links |
| `search_listed` / `_ack` | C→N / N⇒C | signed | whether members' cross-group Search lists this group |
-| `root_update` / `_ack` | C→N / N⇒C | signed | a root's `writable` / `removable` flags |
| `root_eject` / `_ack`, `root_plug` / `_ack` | C→N / N⇒C | signed | take a removable root offline, put it back |
| `gek_rotate` / `_ack` | C→N / N→C | signed | node generates a new group key |
| `apps_enabled` / `_ack` | C→N / N⇒C | signed | which group apps are shown |
@@ -2138,8 +2149,8 @@ it back (§3.5).
| `tmdb_override` / `_ack` | C→N / N⇒C | signed | correct a wrong automatic match |
| `tmdb_rematch` / `_ack` | C→N / N⇒C | signed | drop one file's cached match |
| `musicbrainz_enabled` / `_ack` | C→N / N⇒C | signed | per-group MusicBrainz on/off |
-| `root_add` / `_ack`, `root_remove` / `_ack` | C→N / N→C | signed | add or remove a shared directory |
-| `group_attach` / `_ack`, `group_detach` / `_ack` | C→N / N→C | signed | start or stop hosting a group |
+| `root_remove` / `_ack` | C→N / N→C | signed | remove a shared directory |
+| `group_detach` / `_ack` | C→N / N→C | signed | stop hosting a group |
| `node_status` / `_ack` | C→N / N→C | auth (operator) | all groups, roots, daemon state |
| `roster_read` / `_ack` | C→N / N→C | auth (operator) | pinned identities and members |
| `denylist_read` / `_ack` | C→N / N→C | auth (operator) | current refusals |
@@ -2182,7 +2193,7 @@ message:
## 13. Versioning and compatibility
-MNP versions independently of the package version. Current: **`5.0`**; oldest peer
+MNP versions independently of the package version. Current: **`6.0`**; oldest peer
accepted: **`4.0`**.
4.0 is the floor: a member presents a short-lived node-audience token bound to one node
@@ -2197,6 +2208,13 @@ selection, per-account blobs.
four fail with a refusal and everything else works; no node accepts the old subjects,
so nothing is left unsigned on either side. That is why the floor did not move.
+6.0 is a MAJOR that removes three signed operations — `root_add`, `root_update`,
+`group_attach` — rather than changing any (§10.4: nothing over MNP widens what a node
+shares). A 5.x client that sends one gets no answer, as for any unknown type, and
+everything else it does still works; the floor stays at 4.0. The desktop application
+also refuses to sign them, so a node older than 6.0 cannot be driven into them by a
+script in its page either.
+
The two numbers are separate on purpose. `MNP_VERSION` says what this build speaks;
`MNP_MIN_SUPPORTED` says what it will talk to, and moving the second is a decision about
whether an older peer can still do anything useful:
@@ -2414,7 +2432,7 @@ LP(x) = uint32be(len(x)) || x every field, no exceptions
| Constant | Value | Source |
|---|---|---|
-| `MNP_VERSION` | `5.0` | `meshbay_common/__init__.py` |
+| `MNP_VERSION` | `6.0` | `meshbay_common/__init__.py` |
| `MNP_MIN_SUPPORTED` | `4.0` | `handshake.py` |
| `MNP_AUD` / `HUB_API_AUD` | `meshbay:mnp` / `meshbay:hub-api` | `tokens.py` |
| MNP token lifetime | 900 s | `meshbay-hub/auth.py` (`issue_mnp_token`) |
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index e85b260..d631804 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -437,6 +437,11 @@ list you build yourself with invitation codes. Two things follow from that:
| **The CLI**, over SSH | no browser needed, and it works while the daemon is stopped |
| **A paired browser** | the group's Settings and Members tabs, and the Node page in the desktop application |
+Sharing is decided at the node. Adding a directory, and switching it read-write
+or removable, work only on the machine running the node — the desktop
+application, or `meshbay-node root`. From any other browser the group's
+Settings tab still lists the directories and their switches, read-only.
+
On a headless server the CLI is the only path, and it covers everything you
need to run a group. One gap is known: removing **one** device of one member is
only doable from the interface (§7). Start with:
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index cbaabc4..0020084 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -2218,7 +2218,16 @@ function registerBridge() {
await confirmFolder(body.path);
return ['POST', target, body];
},
- updateRoot: (a) => ['PATCH', root(a), anObject(a.updates)],
+ // Opening a folder to writes from every member is asked like sharing it;
+ // closing it, or the removable flag, only narrows.
+ updateRoot: async (a) => {
+ const updates = anObject(a.updates);
+ if (updates.writable === true) {
+ await confirmOrRefuse('native.root_writable_confirm',
+ { name: aText(a.rootName, 'the folder name') });
+ }
+ return ['PATCH', root(a), updates];
+ },
ejectRoot: (a) => ['PUT', `${root(a)}/eject`],
plugRoot: (a) => ['PUT', `${root(a)}/plug`],
removeRoot: (a) => ['DELETE', root(a)],
diff --git a/packages/meshbay-client/src/transcripts.js b/packages/meshbay-client/src/transcripts.js
index 0b6d0c0..63f87b2 100644
--- a/packages/meshbay-client/src/transcripts.js
+++ b/packages/meshbay-client/src/transcripts.js
@@ -30,6 +30,21 @@ function lenPrefixed(prefix, parts) {
return Buffer.concat(chunks);
}
+// The signed operations this application asks a node to perform
+// (meshbay_common/adminop.py). A list, not a pattern: what widens a node's
+// sharing — `root_add`, `root_update`, `group_attach`, gone from MNP 6.0 — is
+// never signed here, so a node older than that cannot be driven into it by a
+// script in the page either.
+const ADMIN_OPS = new Set([
+ 'file_delete', 'dir_delete', 'invite_create', 'invite_link_create',
+ 'invite_cancel', 'member_revoke', 'member_unpin', 'gek_rotate',
+ 'apps_enabled', 'set_scan_settings', 'transfer_limits', 'tmdb_config',
+ 'tmdb_enabled', 'tmdb_override', 'tmdb_rematch', 'musicbrainz_enabled',
+ 'root_remove', 'root_eject', 'root_plug', 'app_directories',
+ 'chat_directory', 'chat_link_preview', 'search_listed', 'chat_epoch',
+ 'group_detach',
+]);
+
// ── Field checks ──────────────────────────────────────────────────────────
//
// Shapes, not trust: what is checked here is that a field is what its name
@@ -143,7 +158,7 @@ function transcriptFor(kind, f, ctx) {
}
case 'admin': {
const op = String(fields.op ?? '');
- if (!/^[a-z_]{1,32}$/.test(op)) refuse('not an operation');
+ if (!ADMIN_OPS.has(op)) refuse('not an operation');
return lenPrefixed(PREFIX.admin, [
enc(op), enc(sameNode()), enc(groupId(fields.groupId)),
enc(text(fields.subject, 'the subject', 16384)),
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index b68e828..c5c04a5 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -260,5 +260,12 @@ __version__ = "0.17.0"
# a refusal, across the break. The break is confined to them, so the floor stays
# at 4.0: everything else a 4.x peer does still works, and nothing is left
# unsigned on either side — no node accepts the old subjects.
-MNP_VERSION = "5.0"
+#
+# 6.0 (2026-10-02) is a MAJOR — three signed operations are removed: `root_add`,
+# `root_update` and `group_attach`. What of the operator's disk is shared, and
+# whether members may write there, is decided on the node's own machine (the
+# desktop application over loopback, behind a native dialog, or the CLI), never
+# over the wire. A 5.x client that sends one gets no answer, as for any unknown
+# type; everything else it does still works, so the floor stays at 4.0.
+MNP_VERSION = "6.0"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 4379519..11100b5 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -101,7 +101,6 @@ OP_TMDB_REMATCH = "tmdb_rematch"
# the lesson was already learned once). Signed for the same reason as
# tmdb_enabled.
OP_MUSICBRAINZ_ENABLED = "musicbrainz_enabled"
-OP_ROOT_ADD = "root_add"
OP_ROOT_REMOVE = "root_remove"
# One op for every application's directories. The subject is
# "<app>:<comma-joined sorted paths>" so what the operator is shown before
@@ -122,11 +121,16 @@ OP_SEARCH_LISTED = "search_listed"
# There is no op for *enabling* chat encryption. It is not a setting — MNP 2.0
# has no plaintext chat to fall back to.
OP_CHAT_EPOCH = "chat_epoch"
-OP_ROOT_UPDATE = "root_update"
OP_ROOT_EJECT = "root_eject"
OP_ROOT_PLUG = "root_plug"
-OP_GROUP_ATTACH = "group_attach"
OP_GROUP_DETACH = "group_detach"
+# OP_ROOT_ADD, OP_ROOT_UPDATE and OP_GROUP_ATTACH are gone (MNP 6.0). Each one
+# chose what of the operator's disk is shared and who may write there, and a
+# signature proves only that the operator's key signed — in a browser, through
+# code the hub serves; on the desktop, through a renderer that parses content
+# from nodes. Sharing a folder, hosting a group and opening a folder to writes
+# are done on the node's own machine (loopback, behind a native dialog) or with
+# the CLI, and a message that does not exist cannot be mis-authorized.
# OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at
# all: the node holds the GEK and wraps it itself, for a key the recipient proved
# they hold (see `join.py` and docs/MESHBAY_DESIGN.md §3.4). The operation existed
@@ -159,17 +163,6 @@ def secret_digest(value: str | None) -> str | None:
return "sha256:" + hashlib.sha256(value.encode()).hexdigest()
-def root_add_subject(path: str, name: str, kind: str, writable: bool,
- removable: bool) -> str:
- return structured_subject({"path": path, "name": name, "kind": kind,
- "writable": writable, "removable": removable})
-
-
-def group_attach_subject(name: str, shared_dir: str, writable: bool) -> str:
- return structured_subject({"name": name, "shared_dir": shared_dir,
- "writable": writable})
-
-
def invite_create_subject(user_id: str, username: str) -> str:
return structured_subject({"user_id": user_id, "username": username})
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index af2d93b..2c7e301 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -224,8 +224,6 @@ class MNP:
INVITE_LINK_RESULT = "invite_link_result" # node → operator: link code + handle, once
NODE_STATUS = "node_status" # operator → node: list all groups + roots
NODE_STATUS_ACK = "node_status_ack" # node → operator: full status
- ROOT_ADD = "root_add" # operator → node: add a directory to a group
- ROOT_ADD_ACK = "root_add_ack" # node → operator: confirmed
ROOT_REMOVE = "root_remove" # operator → node: remove a root from a group
ROOT_REMOVE_ACK = "root_remove_ack" # node → operator: confirmed
# One message for every application's directories, keyed by the app's own
@@ -256,8 +254,6 @@ class MNP:
# key without having to reconnect.
CHAT_EPOCH = "chat_epoch"
CHAT_EPOCH_ACK = "chat_epoch_ack"
- ROOT_UPDATE = "root_update" # operator → node: a root's flags
- ROOT_UPDATE_ACK = "root_update_ack"
ROOT_EJECT = "root_eject" # operator → node: mark removable root as ejected
ROOT_EJECT_ACK = "root_eject_ack"
ROOT_PLUG = "root_plug" # operator → node: re-enable an ejected root
@@ -276,8 +272,6 @@ class MNP:
DENYLIST_READ_ACK = "denylist_read_ack"
DENYLIST_CLEAR = "denylist_clear" # operator → node: remove denylist entry(ies)
DENYLIST_CLEAR_ACK = "denylist_clear_ack"
- GROUP_ATTACH = "group_attach" # operator → node: host a new group
- GROUP_ATTACH_ACK = "group_attach_ack"
GROUP_DETACH = "group_detach" # operator → node: stop hosting a group
GROUP_DETACH_ACK = "group_detach_ack"
NODE_SETTINGS_SET = "node_settings_set" # operator → node: change daemon settings
diff --git a/packages/meshbay-common/tests/test_admin_subject_parity.py b/packages/meshbay-common/tests/test_admin_subject_parity.py
index 7a229a9..59deaab 100644
--- a/packages/meshbay-common/tests/test_admin_subject_parity.py
+++ b/packages/meshbay-common/tests/test_admin_subject_parity.py
@@ -18,9 +18,7 @@ from pathlib import Path
import pytest
from meshbay_common.adminop import (
- group_attach_subject,
invite_create_subject,
- root_add_subject,
secret_digest,
structured_subject,
tmdb_config_subject,
@@ -34,16 +32,13 @@ pytestmark = pytest.mark.skipif(
reason="node or crypto.js unavailable — parity cannot be checked",
)
-ROOT_ADD = [
- ("/srv/Films", "", "generic", False, False),
- ("/srv/Films", "Films", "video", True, True),
- ("C:\\Users\\me\\Share", "Partagé", "photo", True, False),
- ('/srv/a "quoted", odd:name|x', "名前", "audio", False, True),
- ("/srv/tab\there\nnewline\x01ctl", "é", "generic", True, False),
-]
-GROUP_ATTACH = [
- ("photos", "/srv/photos", True),
- ("famille-été", "/mnt/disque externe/Photos", False),
+# The canonical JSON itself, on the values that are hard to get identical:
+# separators, quotes, control characters, non-ASCII, and null/""/false.
+STRUCTURED = [
+ {"path": "/srv/Films", "name": "", "writable": False, "n": None},
+ {"path": "C:\\Users\\me\\Share", "name": "Partagé", "kind": "photo"},
+ {"path": '/srv/a "quoted", odd:name|x', "name": "名前", "removable": True},
+ {"path": "/srv/tab\there\nnewline\x01ctl", "name": "é", "z": "", "a": 0},
]
INVITE_CREATE = [
("0f8fad5b-d9cb-469f-a165-70867728950e", ""),
@@ -59,13 +54,12 @@ _HARNESS = r"""
const fs = require('fs');
globalThis.window = {};
const src = fs.readFileSync(process.argv[2], 'utf8');
-const M = new Function(src + '\nreturn { rootAddSubject, groupAttachSubject, '
+const M = new Function(src + '\nreturn { adminSubject, '
+ 'inviteCreateSubject, tmdbConfigSubject };')();
const v = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
(async () => {
const out = {
- root_add: v.root_add.map((a) => M.rootAddSubject(...a)),
- group_attach: v.group_attach.map((a) => M.groupAttachSubject(...a)),
+ structured: v.structured.map((f) => M.adminSubject(f)),
invite_create: v.invite_create.map((a) => M.inviteCreateSubject(...a)),
tmdb_config: [],
};
@@ -80,7 +74,7 @@ def js(tmp_path_factory):
d = tmp_path_factory.mktemp("subject-parity")
(d / "harness.js").write_text(_HARNESS, encoding="utf-8")
(d / "vectors.json").write_text(json.dumps({
- "root_add": ROOT_ADD, "group_attach": GROUP_ATTACH,
+ "structured": STRUCTURED,
"invite_create": INVITE_CREATE, "tmdb_config": TMDB_CONFIG,
}), encoding="utf-8")
proc = subprocess.run(
@@ -95,14 +89,9 @@ def _bytes(s: str) -> bytes:
return s.encode("utf-8")
-@pytest.mark.parametrize("i,args", list(enumerate(ROOT_ADD)))
-def test_root_add_subject_parity(i, args, js):
- assert _bytes(js["root_add"][i]) == _bytes(root_add_subject(*args))
-
-
-@pytest.mark.parametrize("i,args", list(enumerate(GROUP_ATTACH)))
-def test_group_attach_subject_parity(i, args, js):
- assert _bytes(js["group_attach"][i]) == _bytes(group_attach_subject(*args))
+@pytest.mark.parametrize("i,fields", list(enumerate(STRUCTURED)))
+def test_structured_subject_parity(i, fields, js):
+ assert _bytes(js["structured"][i]) == _bytes(structured_subject(fields))
@pytest.mark.parametrize("i,args", list(enumerate(INVITE_CREATE)))
@@ -116,13 +105,13 @@ def test_tmdb_config_subject_parity(i, args, js):
def test_every_value_changes_the_subject():
- base = ("/srv/Films", "Films", "video", False, False)
- variants = {root_add_subject(*base)}
- for i, other in enumerate(("/srv/Other", "Other", "audio", True, True)):
+ base = ("0f8fad5b-d9cb-469f-a165-70867728950e", "Someone")
+ variants = {invite_create_subject(*base)}
+ for i, other in enumerate(("6a2f41a3-c54c-fce8-32d2-0324e1c32e22", "Somebody")):
args = list(base)
args[i] = other
- variants.add(root_add_subject(*args))
- assert len(variants) == 6
+ variants.add(invite_create_subject(*args))
+ assert len(variants) == 3
def test_unchanged_cleared_and_set_are_three_subjects():
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py
index f75d60a..bb52742 100644
--- a/packages/meshbay-common/tests/test_js_python_parity.py
+++ b/packages/meshbay-common/tests/test_js_python_parity.py
@@ -668,7 +668,7 @@ _KIND_FIELDS = {
"chat": {"groupId": "g" * 32, "epoch": 4,
"nonce": base64.b64encode(b"\x01" * 12).decode(),
"ct": base64.b64encode(b"ciphertext").decode()},
- "admin": {"op": "root_add", "nodePk": "Tk9ERVBL", "groupId": "g" * 32,
+ "admin": {"op": "root_remove", "nodePk": "Tk9ERVBL", "groupId": "g" * 32,
"subject": '{"path":"/café"}', "nonce": _NONCE, "ts": 1_700_000_000},
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index c239cc8..ce5ec76 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -328,14 +328,6 @@ async function secretDigest(value) {
.map((b) => b.toString(16).padStart(2, '0')).join('');
}
-function rootAddSubject(path, name, kind, writable, removable) {
- return adminSubject({ path, name, kind, writable, removable });
-}
-
-function groupAttachSubject(name, sharedDir, writable) {
- return adminSubject({ name, shared_dir: sharedDir, writable });
-}
-
function inviteCreateSubject(userId, username) {
return adminSubject({ user_id: userId, username });
}
@@ -625,7 +617,7 @@ window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunkBin,
openGroup, sealGroup,
unwrapGEK, b64encode, b64decode,
- adminTranscript, adminSubject, rootAddSubject, groupAttachSubject,
+ adminTranscript, adminSubject,
inviteCreateSubject, tmdbConfigSubject, handshakeTranscript, handshakeProof, webrtcBinding,
challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual,
deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript,
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 bde218b..eddef8e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -22,21 +22,23 @@ export const INVITE_EMAIL_PREF = 'invite_email';
* 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="live" — a hosted group. What widens the node's sharing — adding
+ * a directory, its `writable` and `removable` switches — goes
+ * through the loopback API only, so only on the node's own
+ * machine. Removing, ejecting and plugging are signed ops
+ * over MNP, or the loopback API when there is no 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).
+ * **The list is shown wherever the operator is.** 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. `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). From a browser the table is read
+ * only where it would widen anything (MNP 6.0).
*
* Props:
* roots — the node's current roots: { name, path, writable,
@@ -75,8 +77,6 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
<p class=${msg.error ? 'error-msg' : 'settings-hint'}
role=${msg.error ? 'alert' : 'status'}
style="margin-top:10px">${msg.text}</p>`;
- 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.
@@ -115,12 +115,21 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
// left out otherwise, rather than printing a row of blanks.
const hasPaths = displayRoots.some((r) => r.path);
- // 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).
+ // Which door a change goes through.
+ //
+ // Widening what the node shares — a new directory, `writable`, `removable` —
+ // only through the loopback API, which exists only on the node's own machine
+ // and where the desktop application asks in a native dialog before anything
+ // is shared or opened to writes. Over MNP these were signed ops, and a
+ // signature proves that the operator's key signed, not that the operator
+ // meant it: in a browser that key is driven by code the hub serves.
+ //
+ // Narrowing — remove, eject, plug — MNP first, then loopback.
const overMnp = !isLocal && transport && transport.connected;
const overLoopback = !isLocal && !overMnp && nodeAvail;
+ const onNodeMachine = !isLocal && nodeAvail;
const canEdit = isLocal || overMnp || overLoopback;
+ const canWiden = isLocal || onNodeMachine;
// Deliberately no index refresh after a root change.
//
@@ -158,9 +167,8 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
...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.op('updateRoot', { groupId, rootName, updates });
- else throw new Error(t('node.root_no_route'));
+ if (onNodeMachine) await platform.node.op('updateRoot', { groupId, rootName, updates });
+ else throw new Error(t('settings_node.roots_local_only_hint'));
});
// 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.
@@ -169,8 +177,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
const next = { ...prev }; delete next[rootName]; return next;
});
}
- }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
- transport, groupId, signFn, run]);
+ }, [isLocal, localRoots, onLocalRootsChange, onNodeMachine, groupId, run]);
const doEjectRoot = useCallback((rootName) => run(async () => {
if (overMnp) await transport.ejectRoot(groupId, rootName, signFn);
@@ -203,10 +210,9 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
}, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
transport, groupId, signFn, run]);
- // 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.
+ // Adding a root: a native folder picker on the node's own machine, and
+ // nothing anywhere else — a path typed into a page is a path a script in the
+ // page could have typed.
const addRootAtPath = useCallback(async (path, name) => {
if (isLocal) {
if ((localRoots || []).some(r => r.path === path)) return true;
@@ -222,16 +228,12 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
}
setIndexProgress(null);
return run(async () => {
- if (overMnp) {
- await transport.addRoot(groupId, path, { name }, signFn);
- } else if (overLoopback) {
- await platform.node.op('addRoot', { groupId, path, name });
- await platform.node.op('reload');
- await platform.watchIndexProgress(groupId, setIndexProgress);
- } else throw new Error(t('node.root_no_route'));
+ if (!onNodeMachine) throw new Error(t('settings_node.roots_local_only_hint'));
+ await platform.node.op('addRoot', { groupId, path, name });
+ await platform.node.op('reload');
+ await platform.watchIndexProgress(groupId, setIndexProgress);
});
- }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
- transport, groupId, signFn, run]);
+ }, [isLocal, localRoots, onLocalRootsChange, onNodeMachine, groupId, run]);
const doPickRoot = useCallback(async () => {
const chosen = await platform.rootPicker.choose();
@@ -240,48 +242,23 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
if (ok && !isLocal) say(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) say(t('node.root_added')); }
- }, [pathDraft, addRootAtPath, isLocal]);
-
- 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>
- `}
+ const addControls = !canWiden || !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>
`;
+ // Said once, under the table, rather than as a tooltip on each switch: the
+ // switches are not broken, they are somewhere else.
+ const localOnlyHint = canEdit && !canWiden && html`
+ <p class="settings-hint" style="margin-top:8px">
+ ${t('settings_node.roots_local_only_hint')}</p>`;
if (!displayRoots.length) {
return html`
<div class="shared-directories-table">
<p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ ${localOnlyHint}
${addControls}
${message}
</div>
@@ -326,14 +303,15 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
doing the job. */''}
${canEdit && html`
<td class="sdt-col-toggle">
- <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected}
+ <${ToggleSwitch} checked=${!!r.writable}
+ disabled=${busy || !!r.ejected || !canWiden}
label=${t('node.root_rw')}
onChange=${(v) => doUpdateRoot(r.name, { writable: v })} />
</td>
`}
${canEdit && !isLocal && html`
<td class="sdt-col-toggle">
- <${ToggleSwitch} checked=${!!r.removable} disabled=${busy}
+ <${ToggleSwitch} checked=${!!r.removable} disabled=${busy || !canWiden}
label=${t('node.removable')}
onChange=${(v) => doUpdateRoot(r.name, { removable: v })} />
</td>
@@ -360,6 +338,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
`; })}
</tbody>
</table>
+ ${localOnlyHint}
${addControls}
${message}
${indexProgress && indexProgress.scanning && html`
@@ -426,6 +405,9 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// Node loopback state (Electron-only)
const [nodeDetected, setNodeDetected] = useState(false);
+ // A node on this machine is not necessarily the one hosting this group, and
+ // the table's loopback door is only a door to *that* node.
+ const [nodeHostsGroup, setNodeHostsGroup] = useState(false);
const [nodeRoots, setNodeRoots] = useState([]);
const [nodeGroupName, setNodeGroupName] = useState('');
const [nodeBusy, setNodeBusy] = useState(false);
@@ -464,6 +446,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const data = await platform.node.op('groups');
const groups = data.groups || [];
const ng = groups.find(g => g.id === groupId);
+ setNodeHostsGroup(Boolean(ng));
if (ng) {
setNodeRoots(ng.roots || []);
setNodeGroupName(ng.name || '');
@@ -1182,7 +1165,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
groupId=${groupId}
transport=${transportRef.current}
signFn=${adminSignFn}
- nodeDetected=${nodeDetected}
+ nodeDetected=${nodeDetected && nodeHostsGroup}
onRootsChange=${loadNodeInfo}
onRefreshIndex=${onRefreshIndex} />
</${CollapsibleSection}>
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 c650f75..003a770 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -956,8 +956,6 @@ export default {
'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.',
@@ -1079,6 +1077,7 @@ export default {
'settings_app.tmdb_token_prompt': 'Registrieren Sie sich bei TMDB, um einen eigenen API-Schlüssel zu erzeugen.',
'settings_app.tmdb_token_link': 'Schlüssel holen',
'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.',
+ 'settings_node.roots_local_only_hint': 'Ein Verzeichnis hinzufügen sowie Lesen/Schreiben oder Wechselmedium umschalten geht nur auf dem Rechner, auf dem der Node läuft — in der Desktop-Anwendung oder mit meshbay-node root.',
'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.',
@@ -1252,6 +1251,7 @@ export default {
'group.browser_access_off': 'Der Browserzugang ist für dieses Konto ausgeschaltet. Schalten Sie ihn in der MeshBay-Desktopanwendung (Profil) ein oder geben Sie diesen Browser dort frei.',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': 'Den Ordner {path} mit den Mitgliedern einer auf diesem Computer gehosteten Gruppe teilen? Er wurde nicht in der Ordnerauswahl gewählt.',
+ 'native.root_writable_confirm': 'Den Mitgliedern einer auf diesem Computer gehosteten Gruppe erlauben, in {name} zu schreiben?',
'native.node_account_confirm': 'Der Node auf diesem Computer ist für {current} eingerichtet. Stattdessen für {next} einrichten? Er stellt dann die Gruppen, die er für {current} hostet, nicht mehr bereit.',
'native.browser_access_confirm': 'Die Anmeldung über einen Browser erlauben? Die Anwendung legt Ihre Identität auf jedem genutzten Node ab, so versiegelt, dass nur Ihre Passphrase zusammen mit Ihrem Hub-Konto sie öffnen kann.',
'native.declined': 'Abgebrochen — nichts wurde geändert.',
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 b4e4adf..1d22e56 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -778,6 +778,7 @@ export default {
'settings_app.tmdb_token_prompt': 'Sign up on TMDB to generate your own API key.',
'settings_app.tmdb_token_link': 'Get a key',
'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.roots_local_only_hint': 'Adding a directory, and switching read-write or removable, are done on the machine running the node — in the desktop application, or with meshbay-node root.',
'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).',
@@ -1030,8 +1031,6 @@ export default {
'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.',
@@ -1233,6 +1232,7 @@ export default {
'group.browser_access_off': 'Browser access is off for this account. Turn it on in the MeshBay desktop application (Profile), or approve this browser from it.',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': 'Share the folder {path} with the members of a group hosted on this computer? It was not chosen in the folder picker.',
+ 'native.root_writable_confirm': 'Let the members of a group hosted on this computer write into {name}?',
'native.node_account_confirm': 'The node on this computer is set up for {current}. Set it up for {next} instead? It will stop serving the groups it hosts for {current}.',
'native.browser_access_confirm': 'Allow signing in from a browser? The application will leave your identity on every node you use, sealed so that only your passphrase together with your hub account can open it.',
'native.declined': 'Cancelled — nothing was changed.',
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 7422b13..b6d4b10 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -950,8 +950,6 @@ export default {
'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.',
@@ -1073,6 +1071,7 @@ export default {
'settings_app.tmdb_token_prompt': 'Regístrate en TMDB para generar tu propia clave de API.',
'settings_app.tmdb_token_link': 'Obtener una clave',
'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.roots_local_only_hint': 'Añadir una carpeta y cambiar lectura-escritura o extraíble se hace en la máquina del nodo: en la aplicación de escritorio o con meshbay-node root.',
'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.',
@@ -1246,6 +1245,7 @@ export default {
'group.browser_access_off': 'El acceso desde el navegador está desactivado para esta cuenta. Actívelo en la aplicación de escritorio de MeshBay (Perfil) o apruebe este navegador desde ella.',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': '¿Compartir la carpeta {path} con los miembros de un grupo alojado en este ordenador? No se eligió en el selector de carpetas.',
+ 'native.root_writable_confirm': '¿Permitir que los miembros de un grupo alojado en este ordenador escriban en {name}?',
'native.node_account_confirm': 'El node de este ordenador está configurado para {current}. ¿Configurarlo para {next} en su lugar? Dejará de servir los grupos que aloja para {current}.',
'native.browser_access_confirm': '¿Permitir el acceso desde un navegador? La aplicación dejará su identidad en cada node que use, sellada de modo que solo su frase de contraseña, junto con su cuenta del hub, pueda abrirla.',
'native.declined': 'Cancelado: no se ha cambiado nada.',
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 d7d9228..2c7a148 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -959,8 +959,6 @@ export default {
'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 ?',
@@ -1091,6 +1089,7 @@ export default {
'settings_app.tmdb_token_prompt': 'Créez un compte TMDB pour générer votre propre clé d\'API.',
'settings_app.tmdb_token_link': 'Obtenir une clé',
'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.roots_local_only_hint': 'L\'ajout d\'un dossier et le passage en lecture-écriture ou amovible se font sur la machine du nœud — dans l\'application de bureau, ou avec meshbay-node root.',
'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.',
@@ -1261,6 +1260,7 @@ export default {
'group.browser_access_off': 'L\'accès depuis un navigateur est désactivé pour ce compte. Activez-le dans l\'application de bureau MeshBay (Profil), ou approuvez ce navigateur depuis celle-ci.',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': 'Partager le dossier {path} avec les membres d\'un groupe hébergé sur cet ordinateur ? Il n\'a pas été choisi dans le sélecteur de dossier.',
+ 'native.root_writable_confirm': 'Autoriser les membres d\'un groupe hébergé sur cet ordinateur à écrire dans {name} ?',
'native.node_account_confirm': 'Le node de cet ordinateur est configuré pour {current}. Le configurer pour {next} à la place ? Il cessera de servir les groupes qu\'il héberge pour {current}.',
'native.browser_access_confirm': 'Autoriser la connexion depuis un navigateur ? L\'application déposera votre identité sur chaque node que vous utilisez, scellée de sorte que seule votre phrase secrète, avec votre compte sur le hub, puisse l\'ouvrir.',
'native.declined': 'Annulé — rien n\'a été modifié.',
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 5052378..75fb4ca 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -958,8 +958,6 @@ export default {
'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.',
@@ -1087,6 +1085,7 @@ export default {
'settings_app.tmdb_token_prompt': 'Registrati su TMDB per generare la tua chiave API.',
'settings_app.tmdb_token_link': 'Ottieni una chiave',
'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.',
+ 'settings_node.roots_local_only_hint': 'L\'aggiunta di una cartella e il passaggio a lettura-scrittura o rimovibile si fanno sulla macchina del nodo: nell\'applicazione desktop o con meshbay-node root.',
'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.',
@@ -1260,6 +1259,7 @@ export default {
'group.browser_access_off': 'L\'accesso dal browser è disattivato per questo account. Attivalo nell\'applicazione desktop di MeshBay (Profilo) o approva questo browser da lì.',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': 'Condividere la cartella {path} con i membri di un gruppo ospitato su questo computer? Non è stata scelta nel selettore di cartelle.',
+ 'native.root_writable_confirm': 'Consentire ai membri di un gruppo ospitato su questo computer di scrivere in {name}?',
'native.node_account_confirm': 'Il node di questo computer è configurato per {current}. Configurarlo invece per {next}? Smetterà di servire i gruppi che ospita per {current}.',
'native.browser_access_confirm': 'Consentire l\'accesso da un browser? L\'applicazione lascerà la tua identità su ogni node che usi, sigillata in modo che solo la tua passphrase, insieme al tuo account sull\'hub, possa aprirla.',
'native.declined': 'Annullato: non è stato modificato nulla.',
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 47a968c..6350811 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -944,8 +944,6 @@ export default {
'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': 'ディレクトリを追加しました。',
@@ -1071,6 +1069,7 @@ export default {
'settings_app.tmdb_token_prompt': 'TMDB に登録して、自分の API キーを発行してください。',
'settings_app.tmdb_token_link': 'キーを取得',
'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。',
+ 'settings_node.roots_local_only_hint': 'ディレクトリの追加と、読み書き・リムーバブルの切り替えは、ノードが動いているマシンで行います — デスクトップアプリ、または meshbay-node root で。',
'settings_node.directories_title': 'アプリのディレクトリ',
'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。',
@@ -1244,6 +1243,7 @@ export default {
'group.browser_access_off': 'このアカウントではブラウザーからのアクセスがオフです。MeshBay デスクトップアプリ(プロフィール)でオンにするか、アプリからこのブラウザーを承認してください。',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': 'このコンピューターでホストしているグループのメンバーとフォルダー {path} を共有しますか?このフォルダーはフォルダー選択画面で選ばれたものではありません。',
+ 'native.root_writable_confirm': 'このコンピューターでホストしているグループのメンバーに {name} への書き込みを許可しますか?',
'native.node_account_confirm': 'このコンピューターの node は {current} 用に設定されています。代わりに {next} 用に設定しますか?{current} のためにホストしているグループは提供されなくなります。',
'native.browser_access_confirm': 'ブラウザーからのサインインを許可しますか?アプリは、利用中のすべての node にあなたの ID を残します。これは、パスフレーズと hub のアカウントの両方がそろった場合にのみ開けるよう封印されます。',
'native.declined': 'キャンセルしました。何も変更されていません。',
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 eaad700..3adb51e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -960,8 +960,6 @@ export default {
'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.',
@@ -1089,6 +1087,7 @@ export default {
'settings_app.tmdb_token_prompt': 'Meld u aan bij TMDB om uw eigen API-sleutel te maken.',
'settings_app.tmdb_token_link': 'Sleutel ophalen',
'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.roots_local_only_hint': 'Een map toevoegen en lezen-schrijven of verwisselbaar omzetten gebeurt op de machine van de node — in de desktopapplicatie of met meshbay-node root.',
'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.',
@@ -1262,6 +1261,7 @@ export default {
'group.browser_access_off': 'Browsertoegang staat uit voor dit account. Zet die aan in de MeshBay-desktoptoepassing (Profiel), of keur deze browser daar goed.',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': 'De map {path} delen met de leden van een groep die op deze computer wordt gehost? Hij is niet gekozen in de mapkiezer.',
+ 'native.root_writable_confirm': 'De leden van een groep die op deze computer wordt gehost laten schrijven in {name}?',
'native.node_account_confirm': 'De node op deze computer is ingesteld voor {current}. In plaats daarvan instellen voor {next}? Hij stopt dan met het aanbieden van de groepen die hij voor {current} host.',
'native.browser_access_confirm': 'Aanmelden vanuit een browser toestaan? De toepassing laat je identiteit achter op elke node die je gebruikt, zo verzegeld dat alleen je wachtzin samen met je hub-account haar kan openen.',
'native.declined': 'Geannuleerd — er is niets gewijzigd.',
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 2635fb0..be1ae4f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -982,8 +982,6 @@ export default {
'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.',
@@ -1115,6 +1113,7 @@ export default {
'settings_app.tmdb_token_prompt': 'Zarejestruj się w TMDB, aby wygenerować własny klucz API.',
'settings_app.tmdb_token_link': 'Pobierz klucz',
'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.roots_local_only_hint': 'Dodanie katalogu oraz przełączenie odczytu-zapisu lub nośnika wymiennego odbywa się na komputerze węzła — w aplikacji desktopowej lub poleceniem meshbay-node root.',
'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.',
@@ -1288,6 +1287,7 @@ export default {
'group.browser_access_off': 'Dostęp z przeglądarki jest wyłączony dla tego konta. Włącz go w aplikacji desktopowej MeshBay (Profil) albo zatwierdź tę przeglądarkę w tej aplikacji.',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': 'Udostępnić folder {path} członkom grupy hostowanej na tym komputerze? Nie został wybrany w oknie wyboru folderu.',
+ 'native.root_writable_confirm': 'Pozwolić członkom grupy hostowanej na tym komputerze zapisywać w {name}?',
'native.node_account_confirm': 'Node na tym komputerze jest skonfigurowany dla {current}. Skonfigurować go zamiast tego dla {next}? Przestanie obsługiwać grupy, które hostuje dla {current}.',
'native.browser_access_confirm': 'Zezwolić na logowanie z przeglądarki? Aplikacja pozostawi Twoją tożsamość na każdym używanym node, zapieczętowaną tak, by otworzyć ją mogło tylko Twoje hasło wraz z kontem na hubie.',
'native.declined': 'Anulowano — nic nie zostało zmienione.',
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 207c1b7..9cbfb93 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
@@ -951,8 +951,6 @@ export default {
'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.',
@@ -1074,6 +1072,7 @@ export default {
'settings_app.tmdb_token_prompt': 'Cadastre-se no TMDB para gerar sua própria chave de API.',
'settings_app.tmdb_token_link': 'Obter uma chave',
'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.roots_local_only_hint': 'Adicionar uma pasta e alternar leitura-gravação ou removível são feitos na máquina do nó — no aplicativo de desktop ou com meshbay-node root.',
'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.',
@@ -1247,6 +1246,7 @@ export default {
'group.browser_access_off': 'O acesso pelo navegador está desativado para esta conta. Ative-o no aplicativo de desktop do MeshBay (Perfil) ou aprove este navegador por ele.',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': 'Compartilhar a pasta {path} com os membros de um grupo hospedado neste computador? Ela não foi escolhida no seletor de pastas.',
+ 'native.root_writable_confirm': 'Permitir que os membros de um grupo hospedado neste computador gravem em {name}?',
'native.node_account_confirm': 'O node deste computador está configurado para {current}. Configurá-lo para {next} em vez disso? Ele deixará de servir os grupos que hospeda para {current}.',
'native.browser_access_confirm': 'Permitir o acesso por um navegador? O aplicativo deixará sua identidade em cada node que você usa, selada de forma que apenas sua frase secreta, junto com sua conta no hub, possa abri-la.',
'native.declined': 'Cancelado — nada foi alterado.',
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 3ea8699..6409444 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
@@ -932,8 +932,6 @@ export default {
'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': '目录已添加。',
@@ -1059,6 +1057,7 @@ export default {
'settings_app.tmdb_token_prompt': '在 TMDB 注册以生成你自己的 API 密钥。',
'settings_app.tmdb_token_link': '获取密钥',
'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。',
+ 'settings_node.roots_local_only_hint': '添加目录以及切换读写或可移除,只能在运行节点的机器上进行 — 在桌面应用中,或使用 meshbay-node root。',
'settings_node.directories_title': '应用目录',
'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。',
@@ -1233,6 +1232,7 @@ export default {
'group.browser_access_off': '此账户已关闭浏览器访问。请在 MeshBay 桌面应用(个人资料)中开启,或从该应用批准此浏览器。',
// Worded by the desktop main process for its own dialogs (main.js).
'native.folder_confirm': '与这台电脑上托管的群组成员共享文件夹 {path}?该文件夹不是在文件夹选择器中选择的。',
+ 'native.root_writable_confirm': '允许这台电脑上托管的群组成员写入 {name}?',
'native.node_account_confirm': '这台电脑上的 node 已为 {current} 设置。改为为 {next} 设置吗?它将不再为 {current} 提供其托管的群组。',
'native.browser_access_confirm': '允许从浏览器登录吗?应用会在您使用的每个 node 上留下您的身份,并加以封存,只有您的密码短语配合您的 hub 账户才能打开。',
'native.declined': '已取消,未做任何更改。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index fc91596..23013a4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -3346,13 +3346,6 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
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-surface); 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; }
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
index 1a5a4c0..32307c5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
@@ -255,23 +255,6 @@ extendTransport(class {
return msg;
}
- async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) {
- const msg = await this._sendAndWait({
- type: 'root_add', v: '1.1',
- group_id: groupId, path,
- name: name || '', kind: kind || 'generic',
- writable: !!writable, removable: !!removable,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- // Everything the node will act on is in the subject, `writable` included.
- const subject = window.MeshBayCrypto.rootAddSubject(
- path, name || '', kind || 'generic', !!writable, !!removable);
- return this._authorizeAdminOp(msg, 'root_add', subject, signFn);
- }
- return msg;
- }
-
async removeRoot(groupId, rootName, signFn) {
const msg = await this._sendAndWait({
type: 'root_remove', v: '0.1',
@@ -284,24 +267,6 @@ extendTransport(class {
return msg;
}
- async updateRoot(groupId, rootName, { writable, removable } = {}, signFn) {
- const updates = [];
- if (writable !== undefined) updates.push(`rw=${writable ? 'on' : 'off'}`);
- if (removable !== undefined) updates.push(`rem=${removable ? 'on' : 'off'}`);
- const subject = updates.length ? `${rootName}:${updates.join(',')}` : rootName;
- const msg = await this._sendAndWait({
- type: 'root_update', v: '1.1',
- group_id: groupId, root_name: rootName,
- ...(writable !== undefined && { writable }),
- ...(removable !== undefined && { removable }),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'root_update', subject, signFn);
- }
- return msg;
- }
-
async ejectRoot(groupId, rootName, signFn) {
const msg = await this._sendAndWait({
type: 'root_eject', v: '1.1',
@@ -370,20 +335,6 @@ extendTransport(class {
return msg;
}
- async attachGroup(name, sharedDir, uploadDir, signFn) {
- const msg = await this._sendAndWait({
- type: 'group_attach', v: '0.1',
- name, shared_dir: sharedDir, upload_dir: uploadDir || '', writable: true,
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- // The directory being exposed is signed, not only the group's name.
- const subject = window.MeshBayCrypto.groupAttachSubject(name, sharedDir, true);
- return this._authorizeAdminOp(msg, 'group_attach', subject, signFn);
- }
- return msg;
- }
-
async detachGroup(name, signFn) {
const msg = await this._sendAndWait({
type: 'group_detach', v: '0.1', name,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
index cf53c08..b4d4048 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
@@ -99,7 +99,7 @@ extendTransport(class {
* 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/updateRoot — an unsigned change would let any
+ * setAppsEnabled — an unsigned change would let any
* member alter outbound third-party network traffic the operator never
* agreed to (docs/MESHBAY_DESIGN.md §9.7, §6.5). `token: ''` explicitly clears
* a previously-set custom token; omit it (undefined/null), like
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index f9e1370..e60f673 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -193,8 +193,7 @@ function _aborted() {
// table, then on Chat's directory, where it meant the pane went on showing an
// unsaved-looking draft after a save that had worked.
const BROADCAST_ACK_TYPES = new Set([
- 'root_update_ack', 'root_eject_ack', 'root_plug_ack',
- 'root_add_ack', 'root_remove_ack',
+ 'root_eject_ack', 'root_plug_ack', 'root_remove_ack',
'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack',
'chat_epoch_ack', 'search_listed_ack',
]);
@@ -220,9 +219,9 @@ const ADMIN_OP_TYPES = new Set([
'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled',
'musicbrainz_enabled', 'file_delete', 'dir_delete',
'apps_enabled', 'set_scan_settings', 'member_revoke',
- 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug',
+ 'root_remove', 'root_eject', 'root_plug',
'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch',
- 'search_listed', 'member_unpin', 'gek_rotate', 'group_attach',
+ 'search_listed', 'member_unpin', 'gek_rotate',
'group_detach', 'invite_create', 'invite_link_create', 'invite_cancel',
]);
@@ -367,9 +366,10 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '5.0';
-// Not raised with 5.0 (see meshbay_common/__init__.py): the break is confined to
-// four signed operations, which a peer on the other side of it refuses to sign.
+const MNP_V = '6.0';
+// Not raised with 5.0 or 6.0 (see meshbay_common/__init__.py): each break is
+// confined to a few signed operations — 5.0 changed four subjects, 6.0 removed
+// root_add, root_update and group_attach — and everything else still works.
// Set at 4.0, a flag day. A member now presents a short-lived
// MNP-audience token in the handshake, not its hub session token — a node
// older than 4.0 expected the session token, and one newer refuses it, so the
@@ -2030,11 +2030,11 @@ class MeshBayTransport {
this._onMusicbrainzEnabled(Boolean(msg.enabled));
}
- // 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 (msg.type === 'root_update_ack' || msg.type === 'root_eject_ack'
- || msg.type === 'root_plug_ack' || msg.type === 'root_add_ack'
+ // A root was ejected, plugged or removed. Broadcast by the node to every
+ // peer, so everyone's table updates without waiting for the next index_sync.
+ // A root added or a flag changed — on the node's own machine, never over
+ // MNP — arrives in the index_delta the node pushes.
+ if (msg.type === 'root_eject_ack' || msg.type === 'root_plug_ack'
|| msg.type === 'root_remove_ack') {
if (this._onRootsChanged) this._onRootsChanged(msg);
}
diff --git a/packages/meshbay-hub/tests/test_desktop_keyring.py b/packages/meshbay-hub/tests/test_desktop_keyring.py
index e55e6d0..af7cc37 100644
--- a/packages/meshbay-hub/tests/test_desktop_keyring.py
+++ b/packages/meshbay-hub/tests/test_desktop_keyring.py
@@ -13,6 +13,7 @@ page, and a reference written from the specification in Python.
import base64
import hashlib
import json
+import re
import shutil
import subprocess
from pathlib import Path
@@ -93,6 +94,9 @@ const v = JSON.parse(fs.readFileSync(input, 'utf8'));
other_node: await refusal('join', { ...F.join, nodePk: 'T3RoZXJOb2Rl' }),
other_account: await refusal('join', { ...F.join, userId: 'someone-else' }),
stale: await refusal('join', { ...F.join, ts: ts - 3600 }),
+ root_add: await refusal('admin', { ...F.admin, op: 'root_add' }),
+ root_update: await refusal('admin', { ...F.admin, op: 'root_update' }),
+ group_attach: await refusal('admin', { ...F.admin, op: 'group_attach' }),
};
const eph = require('crypto').generateKeyPairSync('x25519');
@@ -268,6 +272,24 @@ def test_the_page_names_a_kind_and_never_the_bytes(out):
assert "not now" in r["stale"]
+def test_what_widens_a_nodes_sharing_is_never_signed(out):
+ """Gone from MNP 6.0, and refused here as well: a node older than that
+ still accepts them, and a script in the page must not be able to get one
+ signed for it."""
+ for op in ("root_add", "root_update", "group_attach"):
+ assert "not an operation" in out["refused"][op], op
+
+
+def test_the_application_signs_exactly_the_nodes_operations():
+ from meshbay_common import adminop
+ src = (Path(__file__).resolve().parents[2] / "meshbay-client" / "src"
+ / "transcripts.js").read_text(encoding="utf-8")
+ listed = set(re.findall(r"'([a-z_]+)'",
+ src.split("const ADMIN_OPS = new Set([")[1].split("]);")[0]))
+ catalogue = {v for k, v in vars(adminop).items() if k.startswith("OP_")}
+ assert listed == catalogue
+
+
def test_nothing_is_sealed_for_a_browser_while_browser_access_is_off(out):
for what, err in out["sealing_while_access_off"].items():
assert err and "browser access is off" in err, what
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
index e80933c..311e613 100644
--- a/packages/meshbay-hub/tests/test_desktop_shell.py
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -425,9 +425,10 @@ def test_the_page_names_node_operations_not_routes():
assert defined <= used, f"defined but never called: {defined - used}"
-@pytest.mark.parametrize("op", ["attachGroup", "addRoot", "clearDenylist"])
+@pytest.mark.parametrize("op", ["attachGroup", "addRoot", "updateRoot", "clearDenylist"])
def test_what_widens_the_node_is_confirmed_natively(op):
- """Sharing a folder, hosting a group, re-admitting a revoked subject: asked
+ """Sharing a folder, hosting a group, opening a folder to every member's
+ writes, re-admitting a revoked subject: asked
by a dialog the main process draws, which a script in the page cannot
answer. A folder chosen in the native picker is its own confirmation."""
body = _node_ops()[op]
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
index 6316e44..947ba75 100644
--- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -231,7 +231,7 @@ def test_the_notice_also_answers_the_operators_own_request():
def test_changing_a_root_is_signed():
transport = transport_source()
- for method in ("updateRoot", "ejectRoot", "plugRoot"):
+ for method in ("ejectRoot", "plugRoot", "removeRoot"):
body = transport[transport.index(f"async {method}("):]
body = body[:body.index("\n async ", 1)]
assert "admin_challenge" in body and "_authorizeAdminOp" in body, (
@@ -261,12 +261,34 @@ def test_the_operator_is_offered_it_on_the_web_too():
"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"):
+ for call in ("transport.ejectRoot", "transport.plugRoot", "transport.removeRoot"):
assert call in table, f"{call} has no MNP route from the table"
+def test_what_widens_the_sharing_never_crosses_mnp():
+ """
+ Adding a directory and switching `writable` or `removable` are done on the
+ node's own machine (MNP 6.0). Over MNP they were signed ops, and a signature
+ proves that the operator's key signed: in a browser that key is driven by
+ code the hub serves. The list stays visible from anywhere; those controls
+ are read-only there.
+ """
+ transport = transport_source()
+ for method in ("addRoot", "updateRoot", "attachGroup"):
+ assert f"async {method}(" not in transport, f"{method} is an MNP call again"
+ for mtype in ("'root_add'", "'root_update'", "'group_attach'"):
+ assert mtype not in transport, f"{mtype} is sent or expected again"
+
+ table = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "SharedDirectoriesTable")
+ assert "platform.node.op('addRoot'" in table
+ assert "platform.node.op('updateRoot'" in table
+ assert "const canWiden = isLocal || onNodeMachine;" in table
+ assert table.count("!canWiden") >= 3, (
+ "a writable or removable switch is live where it cannot be honoured")
+ assert "settings_node.roots_local_only_hint" in 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
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index 4b619d7..f0505f7 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -719,6 +719,21 @@ class DirectoryIndexer:
# The table now; the files when the scan ends.
await self.on_change(self)
+ async def publish_roots(self) -> None:
+ """
+ Tell every connected peer the table, after a root's flags were edited
+ in place (`ops.update_root`).
+
+ A reload compares the edited set with itself and finds nothing to do,
+ so without this a directory made writable from the operator's own
+ machine stayed read-only on every open page until something else
+ happened to push the index.
+ """
+ self._index.roots = self.roots.describe()
+ self._index.version = int(time.time())
+ if self.on_change:
+ await self.on_change(self)
+
def _holds(self, root: Root) -> bool:
return any(r.folded == root.folded and r.path == root.path for r in self.roots)
diff --git a/packages/meshbay-node/src/meshbay_node/ops/roots.py b/packages/meshbay-node/src/meshbay_node/ops/roots.py
index 480fe63..9dbade4 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/roots.py
@@ -174,11 +174,14 @@ async def update_root(state: dict, group_id: str, root_name: str, *,
writable=match.writable, removable=match.removable)
# Update the live RootSet so GET /api/groups returns correct data
- # immediately, without waiting for the async reload to finish.
+ # immediately, without waiting for the async reload to finish — and the
+ # indexer's, which is normally the same object but need not be, since it
+ # is the one the table pushed to every peer is read from.
live_roots: RootSet | None = state.get("groups_ctx", {}).get(
group_id, {}).get("roots")
- if live_roots:
- for lr in live_roots.roots:
+ indexer = state.get("indexers", {}).get(group_id)
+ for rootset in {id(x): x for x in (live_roots, indexer and indexer.roots) if x}.values():
+ for lr in rootset.roots:
lr_name = lr.name or str(Path(lr.path).name)
if fold(lr_name) == target:
if writable is not None:
@@ -187,6 +190,9 @@ async def update_root(state: dict, group_id: str, root_name: str, *,
lr.removable = removable
break
+ if indexer:
+ await indexer.publish_roots()
+
# Built from config when there is no live set, never returned empty: an
# empty list is a *valid answer* meaning "this group has no directories",
# and the client cannot tell it from "the node could not say". It would
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
index daaee62..738dbce 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
@@ -18,7 +18,6 @@ from meshbay_common.adminop import (
OP_DIR_DELETE,
OP_FILE_DELETE,
OP_GEK_ROTATE,
- OP_GROUP_ATTACH,
OP_GROUP_DETACH,
OP_INVITE_CANCEL,
OP_INVITE_CREATE,
@@ -26,11 +25,9 @@ from meshbay_common.adminop import (
OP_MEMBER_REVOKE,
OP_MEMBER_UNPIN,
OP_MUSICBRAINZ_ENABLED,
- OP_ROOT_ADD,
OP_ROOT_EJECT,
OP_ROOT_PLUG,
OP_ROOT_REMOVE,
- OP_ROOT_UPDATE,
OP_SEARCH_LISTED,
OP_SET_SCAN_SETTINGS,
OP_TMDB_CONFIG,
@@ -72,17 +69,14 @@ _ADMIN_EXECUTORS = {
OP_TMDB_OVERRIDE: "_admin_exec_tmdb_override",
OP_TMDB_REMATCH: "_admin_exec_tmdb_rematch",
OP_MUSICBRAINZ_ENABLED: "_admin_exec_musicbrainz_enabled",
- OP_ROOT_ADD: "_admin_exec_root_add",
OP_ROOT_REMOVE: "_admin_exec_root_remove",
OP_APP_DIRECTORIES: "_admin_exec_app_directories",
OP_CHAT_DIRECTORY: "_admin_exec_chat_directory",
OP_CHAT_LINK_PREVIEW: "_admin_exec_chat_link_preview",
OP_SEARCH_LISTED: "_admin_exec_search_listed",
OP_CHAT_EPOCH: "_admin_exec_chat_epoch",
- OP_ROOT_UPDATE: "_admin_exec_root_update",
OP_ROOT_EJECT: "_admin_exec_root_eject",
OP_ROOT_PLUG: "_admin_exec_root_plug",
- OP_GROUP_ATTACH: "_admin_exec_group_attach",
OP_GROUP_DETACH: "_admin_exec_group_detach",
}
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
index ee2e3a1..1ba5445 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
@@ -71,15 +71,12 @@ _HANDLERS = {
MNP.MEMBER_UNPIN: ("_do_member_unpin", INLINE),
MNP.GEK_ROTATE: ("_do_gek_rotate", INLINE),
MNP.NODE_STATUS: ("_do_node_status", SPAWNED),
- MNP.ROOT_ADD: ("_do_root_add", INLINE),
MNP.ROOT_REMOVE: ("_do_root_remove", INLINE),
- MNP.ROOT_UPDATE: ("_do_root_update", INLINE),
MNP.ROOT_EJECT: ("_do_root_eject", INLINE),
MNP.ROOT_PLUG: ("_do_root_plug", INLINE),
MNP.ROSTER_READ: ("_do_roster_read", SPAWNED),
MNP.DENYLIST_READ: ("_do_denylist_read", SPAWNED),
MNP.DENYLIST_CLEAR: ("_do_denylist_clear", SPAWNED),
- MNP.GROUP_ATTACH: ("_do_group_attach", INLINE),
MNP.GROUP_DETACH: ("_do_group_detach", INLINE),
MNP.NODE_SETTINGS_SET: ("_do_node_settings_set", SPAWNED),
MNP.NODE_RELOAD: ("_do_node_reload", SPAWNED),
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py
index f7bbbfa..acaa33f 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py
@@ -5,17 +5,12 @@ import logging
from meshbay_common import MNP_VERSION
from meshbay_common.adminop import (
- OP_GROUP_ATTACH,
OP_GROUP_DETACH,
- OP_ROOT_ADD,
OP_ROOT_EJECT,
OP_ROOT_PLUG,
OP_ROOT_REMOVE,
- OP_ROOT_UPDATE,
OP_SET_SCAN_SETTINGS,
OP_TRANSFER_LIMITS,
- group_attach_subject,
- root_add_subject,
)
from meshbay_common.protocol import MNP
@@ -235,52 +230,6 @@ class NodeOpsMixin:
log.error("denylist_clear failed: %s", e, exc_info=True)
self._send({"type": "error", "detail": "Internal error"})
- def _do_group_attach(self, msg: dict) -> None:
- name = str(msg.get("name", "")).strip()
- shared_dir = str(msg.get("shared_dir", "")).strip()
- if not name or not shared_dir:
- self._send({"type": "error", "detail": "Missing name or shared_dir"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- # `upload_dir` is not read here any more, and a client still sending it
- # is ignored rather than obeyed: on load it forces every other root
- # read-only, which is the model the RO/RW one replaced. A second
- # writable directory is `root_add` with `writable`.
- writable = bool(msg.get("writable", True))
- # The directory being exposed is signed, not only the group's name.
- self._issue_admin_challenge(
- OP_GROUP_ATTACH, group_attach_subject(name, shared_dir, writable),
- payload={"name": name, "shared_dir": shared_dir, "writable": writable},
- group_id="")
-
- async def _admin_exec_group_attach(
- self, pending: dict, transcript: bytes, sig: bytes,
- ) -> None:
- if not await self._verify_admin_sig(transcript, sig):
- self._send({"type": "error", "detail": "Signature verification failed"})
- self._audit("admin_auth_failed",
- f"group_attach:{pending['subject'][:16]}")
- return
- p = pending.get("payload") or {}
- try:
- result = await self._run_op(
- ops.attach_group, p["name"], p["shared_dir"],
- writable=bool(p.get("writable", True)))
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- self._audit("group_attach", pending["subject"])
- self._send({"type": MNP.GROUP_ATTACH_ACK, "v": MNP_VERSION, **result})
- state = self._ctx.get("daemon_state")
- reload_fn = state.get("reload_fn") if state else None
- if reload_fn:
- try:
- await reload_fn()
- except Exception as e:
- log.error("Reload after group_attach failed: %s", e)
-
def _do_group_detach(self, msg: dict) -> None:
name = str(msg.get("name", "")).strip()
if not name:
@@ -336,55 +285,6 @@ class NodeOpsMixin:
log.error("node_reload failed: %s", e, exc_info=True)
self._send({"type": "error", "detail": "Reload failed"})
- def _do_root_add(self, msg: dict) -> None:
- target_group = str(msg.get("group_id", "")).strip()
- path = str(msg.get("path", "")).strip()
- if not target_group or not path:
- self._send({"type": "error", "detail": "Missing group_id or path"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- payload = {
- "group_id": target_group, "path": path,
- "name": str(msg.get("name", ""))[:128],
- "kind": str(msg.get("kind", "generic"))[:16],
- "writable": bool(msg.get("writable", msg.get("upload", False))),
- "removable": bool(msg.get("removable", False)),
- }
- # Everything the executor acts on is signed — `writable` decides whether
- # every member may write there. The group is in the transcript itself.
- self._issue_admin_challenge(
- OP_ROOT_ADD,
- root_add_subject(path, payload["name"], payload["kind"],
- payload["writable"], payload["removable"]),
- payload=payload, group_id=target_group)
-
- async def _admin_exec_root_add(
- self, pending: dict, transcript: bytes, sig: bytes,
- ) -> None:
- if not await self._verify_admin_sig(transcript, sig):
- self._send({"type": "error", "detail": "Signature verification failed"})
- self._audit("admin_auth_failed", f"root_add:{pending['subject'][:24]}")
- return
- p = pending["payload"]
- try:
- result = await self._run_op(
- ops.add_root, p["group_id"], p["path"],
- name=p.get("name", ""), kind=p.get("kind", "generic"),
- writable=p.get("writable", False),
- removable=p.get("removable", False))
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- except Exception as e:
- log.error("root_add failed: %s", e, exc_info=True)
- self._send({"type": "error", "detail": "Internal error"})
- return
- self._audit("root_add", f"{p['path']}→{p['group_id'][:8]}")
- await self._retarget_indexer(p["group_id"])
- self._send({"type": MNP.ROOT_ADD_ACK, "v": MNP_VERSION, **result})
-
def _do_root_remove(self, msg: dict) -> None:
target_group = str(msg.get("group_id", "")).strip()
root_name = str(msg.get("root_name", "")).strip()
@@ -421,59 +321,6 @@ class NodeOpsMixin:
await self._retarget_indexer(p["group_id"])
self._send({"type": MNP.ROOT_REMOVE_ACK, "v": MNP_VERSION, **result})
- def _do_root_update(self, msg: dict) -> None:
- target_group = str(msg.get("group_id", self._group_id or "")).strip()
- root_name = str(msg.get("root_name", "")).strip()
- if not target_group or not root_name:
- self._send({"type": "error", "detail": "Missing group_id or root_name"})
- return
- if not self._has_admin_authority():
- self._send({"type": "error", "detail": "No authorized key for this"})
- return
- updates = []
- if "writable" in msg:
- updates.append(f"rw={'on' if msg['writable'] else 'off'}")
- if "removable" in msg:
- updates.append(f"rem={'on' if msg['removable'] else 'off'}")
- subject = f"{root_name}:{','.join(updates)}" if updates else root_name
- self._issue_admin_challenge(
- OP_ROOT_UPDATE, subject,
- payload={
- "group_id": target_group, "root_name": root_name,
- "writable": msg.get("writable"),
- "removable": msg.get("removable"),
- },
- group_id=target_group)
-
- async def _admin_exec_root_update(
- self, pending: dict, transcript: bytes, sig: bytes,
- ) -> None:
- if not await self._verify_admin_sig(transcript, sig):
- self._send({"type": "error", "detail": "Signature verification failed"})
- self._audit("admin_auth_failed",
- f"root_update:{pending['subject'][:24]}")
- return
- p = pending["payload"]
- try:
- result = await self._run_op(
- ops.update_root, p["group_id"], p["root_name"],
- writable=p.get("writable"), removable=p.get("removable"))
- except ops.OpError as e:
- self._send({"type": "error", "detail": e.message})
- return
- except Exception as e:
- log.error("root_update failed: %s", e, exc_info=True)
- self._send({"type": "error", "detail": "Internal error"})
- return
- self._audit("root_update", pending["subject"])
- await self._retarget_indexer(p["group_id"])
- notice = {"type": MNP.ROOT_UPDATE_ACK, "v": MNP_VERSION, **result}
- for uid, session in list(self._peer_registry().items()):
- try:
- session._send(notice)
- except Exception:
- pass
-
def _do_root_eject(self, msg: dict) -> None:
target_group = str(msg.get("group_id", self._group_id or "")).strip()
root_name = str(msg.get("root_name", "")).strip()
@@ -558,24 +405,21 @@ class NodeOpsMixin:
async def _retarget_indexer(self, group_id: str) -> None:
"""
- Pick up a root that was just added to or removed from node.toml.
+ Pick up a root that was just removed from node.toml.
Through the daemon's own reload, which is what the loopback API has
always done after the same operations (`ui/app.py`). This used to
re-point the indexer at `groups_ctx[gid]["roots"]` instead — the very
object the op had just edited — so `retarget` diffed a set against
- itself, found no new names, scanned nothing, and dropped nothing. A
- directory added over MNP reached node.toml and was invisible until a
- restart; one removed kept serving its files.
+ itself, found no new names, scanned nothing, and dropped nothing: a
+ directory removed over MNP kept serving its files until a restart.
Two front doors doing different things is the shape `ops.py` exists to
prevent, and this was it: the loopback path worked and the MNP path did
- not, which is why it survived until the operator added a directory from
- a browser.
+ not.
- Not awaited: a reload rescans, and a new library is minutes. The ack
- the caller sends carries the set the node is moving to, and the
- `index_sync` that follows the scan carries what it found.
+ Not awaited: a reload can be long. The ack the caller sends carries the
+ set the node is moving to.
"""
state = self._ctx.get("daemon_state")
if not state:
diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json
index a7bb543..345b077 100644
--- a/packages/meshbay-node/tests/golden/dispatch.json
+++ b/packages/meshbay-node/tests/golden/dispatch.json
@@ -84,14 +84,6 @@
"_admin_exec_gek_rotate"
]
},
- "group_attach": {
- "audit": [],
- "log": [],
- "sent": [],
- "spawned": [
- "_admin_exec_group_attach"
- ]
- },
"group_detach": {
"audit": [],
"log": [],
@@ -160,14 +152,6 @@
],
"spawned": []
},
- "root_add": {
- "audit": [],
- "log": [],
- "sent": [],
- "spawned": [
- "_admin_exec_root_add"
- ]
- },
"root_eject": {
"audit": [],
"log": [],
@@ -192,14 +176,6 @@
"_admin_exec_root_remove"
]
},
- "root_update": {
- "audit": [],
- "log": [],
- "sent": [],
- "spawned": [
- "_admin_exec_root_update"
- ]
- },
"search_listed": {
"audit": [],
"log": [],
@@ -2068,7 +2044,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2391,7 +2367,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2410,7 +2386,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2429,7 +2405,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2448,7 +2424,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2718,7 +2694,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2732,7 +2708,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2746,7 +2722,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2760,7 +2736,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2774,7 +2750,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2788,7 +2764,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2802,7 +2778,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -2816,7 +2792,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -8535,7 +8511,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -8554,7 +8530,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -8573,7 +8549,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -8592,7 +8568,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -8757,379 +8733,6 @@
"sent": [],
"spawned": []
},
- "group_attach | challenged | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | challenged | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | challenged | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | challenged | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | fresh | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | fresh | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | fresh | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | fresh | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | member | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Missing name or shared_dir",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | member | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "No authorized key for this",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | member | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "No authorized key for this",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | member | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "No authorized key for this",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | operator | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Missing name or shared_dir",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach | operator | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "group_id": "",
- "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
- "nonce": "<volatile>",
- "op": "group_attach",
- "op_id": "<volatile>",
- "req_id": 4242,
- "subject": "{\"name\":\"['x']\",\"shared_dir\":\"['x']\",\"writable\":true}",
- "ts": "<volatile>",
- "type": "admin_challenge",
- "v": "5.0"
- }
- ],
- "spawned": []
- },
- "group_attach | operator | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "group_id": "",
- "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
- "nonce": "<volatile>",
- "op": "group_attach",
- "op_id": "<volatile>",
- "req_id": 4242,
- "subject": "{\"name\":\"7\",\"shared_dir\":\"7\",\"writable\":true}",
- "ts": "<volatile>",
- "type": "admin_challenge",
- "v": "5.0"
- }
- ],
- "spawned": []
- },
- "group_attach | operator | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "group_id": "",
- "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
- "nonce": "<volatile>",
- "op": "group_attach",
- "op_id": "<volatile>",
- "req_id": 4242,
- "subject": "{\"name\":\"x\",\"shared_dir\":\"x\",\"writable\":true}",
- "ts": "<volatile>",
- "type": "admin_challenge",
- "v": "5.0"
- }
- ],
- "spawned": []
- },
- "group_attach_ack | challenged | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach_ack | challenged | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach_ack | challenged | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach_ack | challenged | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach_ack | fresh | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach_ack | fresh | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach_ack | fresh | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach_ack | fresh | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "group_attach_ack | member | bare": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "group_attach_ack | member | lists": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "group_attach_ack | member | numbers": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "group_attach_ack | member | strings": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "group_attach_ack | operator | bare": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "group_attach_ack | operator | lists": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "group_attach_ack | operator | numbers": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "group_attach_ack | operator | strings": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
"group_detach | challenged | bare": {
"audit": [],
"log": [],
@@ -9300,7 +8903,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -9319,7 +8922,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -9338,7 +8941,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -11641,7 +11244,7 @@
"subject": "link:gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -13740,7 +13343,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -13759,7 +13362,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -13778,7 +13381,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -14113,7 +13716,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -14132,7 +13735,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -14151,7 +13754,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -16212,7 +15815,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -16227,7 +15830,7 @@
"x"
],
"type": "pong",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -16240,7 +15843,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -16253,7 +15856,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -16266,7 +15869,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -16281,7 +15884,7 @@
"x"
],
"type": "pong",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -16294,7 +15897,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -16307,7 +15910,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -16472,379 +16075,6 @@
"sent": [],
"spawned": []
},
- "root_add | challenged | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | challenged | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | challenged | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | challenged | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | fresh | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | fresh | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | fresh | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | fresh | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | member | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Missing group_id or path",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | member | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "No authorized key for this",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | member | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "No authorized key for this",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | member | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "No authorized key for this",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | operator | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Missing group_id or path",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add | operator | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "group_id": "['x']",
- "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
- "nonce": "<volatile>",
- "op": "root_add",
- "op_id": "<volatile>",
- "req_id": 4242,
- "subject": "{\"kind\":\"['x']\",\"name\":\"['x']\",\"path\":\"['x']\",\"removable\":true,\"writable\":true}",
- "ts": "<volatile>",
- "type": "admin_challenge",
- "v": "5.0"
- }
- ],
- "spawned": []
- },
- "root_add | operator | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "group_id": "7",
- "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
- "nonce": "<volatile>",
- "op": "root_add",
- "op_id": "<volatile>",
- "req_id": 4242,
- "subject": "{\"kind\":\"7\",\"name\":\"7\",\"path\":\"7\",\"removable\":true,\"writable\":true}",
- "ts": "<volatile>",
- "type": "admin_challenge",
- "v": "5.0"
- }
- ],
- "spawned": []
- },
- "root_add | operator | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "group_id": "x",
- "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
- "nonce": "<volatile>",
- "op": "root_add",
- "op_id": "<volatile>",
- "req_id": 4242,
- "subject": "{\"kind\":\"x\",\"name\":\"x\",\"path\":\"x\",\"removable\":true,\"writable\":true}",
- "ts": "<volatile>",
- "type": "admin_challenge",
- "v": "5.0"
- }
- ],
- "spawned": []
- },
- "root_add_ack | challenged | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add_ack | challenged | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add_ack | challenged | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add_ack | challenged | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add_ack | fresh | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add_ack | fresh | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add_ack | fresh | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add_ack | fresh | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_add_ack | member | bare": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_add_ack | member | lists": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_add_ack | member | numbers": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_add_ack | member | strings": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_add_ack | operator | bare": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_add_ack | operator | lists": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_add_ack | operator | numbers": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_add_ack | operator | strings": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
"root_eject | challenged | bare": {
"audit": [],
"log": [],
@@ -17015,7 +16245,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -17034,7 +16264,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -17053,7 +16283,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -17388,7 +16618,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -17407,7 +16637,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -17426,7 +16656,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -17761,7 +16991,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -17780,7 +17010,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -17799,7 +17029,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -17964,379 +17194,6 @@
"sent": [],
"spawned": []
},
- "root_update | challenged | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | challenged | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | challenged | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | challenged | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | fresh | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | fresh | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | fresh | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | fresh | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | member | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Missing group_id or root_name",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | member | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "No authorized key for this",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | member | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "No authorized key for this",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | member | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "No authorized key for this",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | operator | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Missing group_id or root_name",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update | operator | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "group_id": "['x']",
- "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
- "nonce": "<volatile>",
- "op": "root_update",
- "op_id": "<volatile>",
- "req_id": 4242,
- "subject": "['x']:rw=on,rem=on",
- "ts": "<volatile>",
- "type": "admin_challenge",
- "v": "5.0"
- }
- ],
- "spawned": []
- },
- "root_update | operator | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "group_id": "7",
- "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
- "nonce": "<volatile>",
- "op": "root_update",
- "op_id": "<volatile>",
- "req_id": 4242,
- "subject": "7:rw=on,rem=on",
- "ts": "<volatile>",
- "type": "admin_challenge",
- "v": "5.0"
- }
- ],
- "spawned": []
- },
- "root_update | operator | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "group_id": "x",
- "node_pk": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
- "nonce": "<volatile>",
- "op": "root_update",
- "op_id": "<volatile>",
- "req_id": 4242,
- "subject": "x:rw=on,rem=on",
- "ts": "<volatile>",
- "type": "admin_challenge",
- "v": "5.0"
- }
- ],
- "spawned": []
- },
- "root_update_ack | challenged | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update_ack | challenged | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update_ack | challenged | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update_ack | challenged | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update_ack | fresh | bare": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update_ack | fresh | lists": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update_ack | fresh | numbers": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update_ack | fresh | strings": {
- "audit": [],
- "log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
- },
- "root_update_ack | member | bare": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_update_ack | member | lists": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_update_ack | member | numbers": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_update_ack | member | strings": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_update_ack | operator | bare": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_update_ack | operator | lists": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_update_ack | operator | numbers": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
- "root_update_ack | operator | strings": {
- "audit": [],
- "log": [
- "WARNING Unknown MNP message type on DataChannel: %s"
- ],
- "sent": [],
- "spawned": []
- },
"roster_read | challenged | bare": {
"audit": [],
"log": [],
@@ -21119,7 +19976,7 @@
"subject": "{\"language\":null,\"token\":null}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -21162,7 +20019,7 @@
"subject": "{\"language\":\"x\",\"token\":\"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\"}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
@@ -23049,7 +21906,7 @@
"subject": "d=7,u=7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "5.0"
+ "v": "6.0"
}
],
"spawned": []
diff --git a/packages/meshbay-node/tests/test_admin_challenge_bounds.py b/packages/meshbay-node/tests/test_admin_challenge_bounds.py
index fd3b2f1..9dcb750 100644
--- a/packages/meshbay-node/tests/test_admin_challenge_bounds.py
+++ b/packages/meshbay-node/tests/test_admin_challenge_bounds.py
@@ -3,8 +3,9 @@ What a connection may leave waiting for a signature (docs/MESHBAY_DESIGN.md §13
Anyone authenticated can ask for an admin challenge — the signature is checked
later — so a member who never answers must not make the node keep every request.
-Measured before the bound: 200 `root_add` of 1 MiB each from a plain member held
-200 pending operations and ~400 MiB for the life of the connection.
+Measured before the bound: 200 `root_add` (an op since removed) of 1 MiB each
+from a plain member held 200 pending operations and ~400 MiB for the life of
+the connection.
"""
import struct
@@ -48,23 +49,24 @@ def _member_session():
return s
-def _root_add(s, path: str) -> dict:
- s._dispatch_message({"type": "root_add", "group_id": GROUP, "path": path})
+def _ask(s, path: str) -> dict:
+ """A signed op whose subject is whatever the caller sends."""
+ s._dispatch_message({"type": "chat_directory", "path": path})
return s._channel.sent[-1]
def test_a_member_cannot_pile_up_challenges():
s = _member_session()
for i in range(MAX_PENDING_ADMIN_OPS):
- assert _root_add(s, f"/srv/{i}")["type"] == "admin_challenge"
- refused = _root_add(s, "/srv/one-too-many")
+ assert _ask(s, f"/srv/{i}")["type"] == "admin_challenge"
+ refused = _ask(s, "/srv/one-too-many")
assert refused["type"] == "error" and refused["code"] == "too_many_pending"
assert len(s._admin_ops) == MAX_PENDING_ADMIN_OPS
def test_an_oversized_request_is_not_kept():
s = _member_session()
- refused = _root_add(s, "x" * (MAX_ADMIN_OP_BYTES + 1))
+ refused = _ask(s, "x" * (MAX_ADMIN_OP_BYTES + 1))
assert refused["type"] == "error" and refused["code"] == "too_large"
assert s._admin_ops == {}
@@ -72,21 +74,21 @@ def test_an_oversized_request_is_not_kept():
def test_an_expired_challenge_frees_its_place():
s = _member_session()
for i in range(MAX_PENDING_ADMIN_OPS):
- _root_add(s, f"/srv/{i}")
+ _ask(s, f"/srv/{i}")
for pending in s._admin_ops.values():
pending["ts"] -= 10_000
- assert _root_add(s, "/srv/after-expiry")["type"] == "admin_challenge"
+ assert _ask(s, "/srv/after-expiry")["type"] == "admin_challenge"
assert len(s._admin_ops) == 1
def test_answering_a_challenge_frees_its_place():
s = _member_session()
for i in range(MAX_PENDING_ADMIN_OPS):
- _root_add(s, f"/srv/{i}")
+ _ask(s, f"/srv/{i}")
op_id = next(iter(s._admin_ops))
s._dispatch_message({"type": "admin_response", "op_id": op_id, "signature": "!!"})
assert len(s._admin_ops) == MAX_PENDING_ADMIN_OPS - 1
- assert _root_add(s, "/srv/next")["type"] == "admin_challenge"
+ assert _ask(s, "/srv/next")["type"] == "admin_challenge"
assert all(time.time() - p["ts"] < 5 for p in s._admin_ops.values())
@@ -95,27 +97,6 @@ def test_answering_a_challenge_frees_its_place():
# The signature covers the subject and nothing else of a request, so every value
# the executor acts on has to be in it.
-def test_root_add_signs_whether_members_may_write():
- from meshbay_common.adminop import root_add_subject
- s = _member_session()
- s._dispatch_message({"type": "root_add", "group_id": GROUP, "path": "/srv/drop",
- "name": "Drop", "writable": True, "removable": False})
- challenge = s._channel.sent[-1]
- assert challenge["subject"] == root_add_subject("/srv/drop", "Drop", "generic",
- True, False)
- assert challenge["subject"] != root_add_subject("/srv/drop", "Drop", "generic",
- False, False)
-
-
-def test_group_attach_signs_the_directory_it_exposes():
- from meshbay_common.adminop import group_attach_subject
- s = _member_session()
- s._dispatch_message({"type": "group_attach", "name": "photos",
- "shared_dir": "/home/me/Photos"})
- assert s._channel.sent[-1]["subject"] == group_attach_subject(
- "photos", "/home/me/Photos", True)
-
-
def test_invite_create_signs_the_name_it_records():
from meshbay_common.adminop import invite_create_subject
s = _member_session()
diff --git a/packages/meshbay-node/tests/test_node_status.py b/packages/meshbay-node/tests/test_node_status.py
index bd63f8e..257c60c 100644
--- a/packages/meshbay-node/tests/test_node_status.py
+++ b/packages/meshbay-node/tests/test_node_status.py
@@ -1,9 +1,10 @@
"""
-node_status, root_add, root_remove over MNP.
+node_status and root_remove over MNP.
These test the D5 node management panel's server-side behaviour: the admin
identity check on node_status, the list_groups operation, and the root
-add/remove flows through the MNP handlers.
+removal flow through the MNP handlers. Adding a root is not an MNP message
+(MNP 6.0); `ops.add_root` is still tested here, as the loopback and CLI use it.
"""
import base64
@@ -478,32 +479,6 @@ async def test_remove_root_succeeds_with_two_roots(tmp_path):
assert cfg.roots[0].name == "dir1"
-# ── root_add MNP handler ───────────────────────────────────────────────────
-
-async def test_root_add_issues_challenge(tmp_path, roster):
- session = await _session(tmp_path, roster, operator=True,
- node_user_id="grenet")
- session._do_root_add({"group_id": GROUP, "path": "/tmp/test"})
- msg = _last(session)
- assert msg["type"] == "admin_challenge"
-
-
-async def test_root_add_refuses_without_authority(tmp_path, roster):
- session = await _session(tmp_path, roster, operator=False)
- session._do_root_add({"group_id": GROUP, "path": "/tmp/test"})
- msg = _last(session)
- assert msg["type"] == "error"
- assert "authorized" in msg["detail"].lower()
-
-
-async def test_root_add_refuses_missing_fields(tmp_path, roster):
- session = await _session(tmp_path, roster, operator=True,
- node_user_id="grenet")
- session._do_root_add({"group_id": GROUP})
- assert _last(session)["type"] == "error"
- assert "Missing" in _last(session)["detail"]
-
-
# ── root_remove MNP handler ────────────────────────────────────────────────
async def test_root_remove_issues_challenge(tmp_path, roster):
@@ -677,33 +652,6 @@ async def test_denylist_clear_refused_for_non_admin(tmp_path, roster):
assert msg["type"] == "error"
-# ── group_attach MNP handler ────────────────────────────────────────────
-
-async def test_group_attach_issues_challenge(tmp_path, roster):
- session = await _session(tmp_path, roster, operator=True,
- node_user_id="grenet")
- session._do_group_attach({"name": "test-group", "shared_dir": "/tmp/share"})
- msg = _last(session)
- assert msg["type"] == "admin_challenge"
-
-
-async def test_group_attach_refused_without_authority(tmp_path, roster):
- session = await _session(tmp_path, roster, operator=False)
- session._do_group_attach({"name": "test-group", "shared_dir": "/tmp/share"})
- msg = _last(session)
- assert msg["type"] == "error"
- assert "authorized" in msg["detail"].lower()
-
-
-async def test_group_attach_refuses_missing_fields(tmp_path, roster):
- session = await _session(tmp_path, roster, operator=True,
- node_user_id="grenet")
- session._do_group_attach({"name": "test-group"})
- msg = _last(session)
- assert msg["type"] == "error"
- assert "Missing" in msg["detail"]
-
-
# ── node_reload MNP handler ─────────────────────────────────────────────
async def test_node_reload_runs_for_admin(tmp_path, roster):
diff --git a/packages/meshbay-node/tests/test_root_writable_policy.py b/packages/meshbay-node/tests/test_root_writable_policy.py
index 0cd9af8..0dc5d6d 100644
--- a/packages/meshbay-node/tests/test_root_writable_policy.py
+++ b/packages/meshbay-node/tests/test_root_writable_policy.py
@@ -12,8 +12,9 @@ The properties this holds:
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;
+* the setting is changed **on the node's own machine** — the desktop
+ application over loopback, or the CLI — and never over MNP, where a signature
+ proves only that the operator's key signed (`test_sharing_is_local_only.py`);
* 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.
@@ -26,7 +27,7 @@ from pathlib import Path
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from meshbay_common.adminop import OP_ROOT_EJECT, OP_ROOT_PLUG, OP_ROOT_UPDATE
+from meshbay_common.adminop import OP_ROOT_EJECT, OP_ROOT_PLUG
from meshbay_common.crypto import generate_gek
from meshbay_common.protocol import MNP
from meshbay_node.indexer.group_index import GroupIndex
@@ -163,34 +164,6 @@ def _capture_challenges(session) -> list[tuple[str, str]]:
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
@@ -214,8 +187,7 @@ async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
issued = _capture_challenges(session)
session._has_admin_authority = lambda: False
- session._do_root_update({"group_id": "g" * 32, "root_name": "shared",
- "writable": True})
+ session._do_root_eject({"group_id": "g" * 32, "root_name": "shared"})
assert issued == []
assert [m for m in session.sent if m.get("type") == "error"]
diff --git a/packages/meshbay-node/tests/test_sharing_is_local_only.py b/packages/meshbay-node/tests/test_sharing_is_local_only.py
new file mode 100644
index 0000000..cd550e4
--- /dev/null
+++ b/packages/meshbay-node/tests/test_sharing_is_local_only.py
@@ -0,0 +1,109 @@
+"""
+What of the operator's disk is shared, and who may write there, is decided on
+the node's own machine — never over MNP (MNP 6.0).
+
+A signed operation proves that the operator's key signed, not that the operator
+meant it: in a browser the key is driven by code the hub serves, and in the
+desktop application by a renderer that parses content from nodes. `root_add`,
+`root_update` and `group_attach` let either of them share any folder on the
+machine, or open one to writes, from anywhere. They are gone; the loopback API
+(behind a native dialog in the desktop application) and the CLI remain.
+
+And the consequence that has to hold for the operator's list to stay true: a
+flag changed through the loopback API reaches every connected page, which the
+MNP ack used to do.
+"""
+
+from dataclasses import asdict
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common import adminop
+from meshbay_common.protocol import MNP
+from meshbay_node import ops
+from meshbay_node.config import GroupConfig, RootSpec
+from meshbay_node.indexer.indexer import DirectoryIndexer
+from meshbay_node.roots import RootSet
+from meshbay_node.transport.webrtc.admin import _ADMIN_EXECUTORS
+from meshbay_node.transport.webrtc.dispatch import _HANDLERS
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+from test_admin_challenge_bounds import _PC, _Channel
+
+GROUP = "g" * 32
+GONE = ("root_add", "root_update", "group_attach")
+
+
+@pytest.mark.parametrize("mtype", GONE)
+def test_no_message_widens_what_the_node_shares(mtype):
+ assert mtype not in _HANDLERS, f"{mtype} is dispatched again"
+ assert mtype not in _ADMIN_EXECUTORS, f"{mtype} can be executed again"
+ assert mtype not in vars(MNP).values(), f"{mtype} is back in the protocol"
+ assert mtype not in vars(adminop).values(), f"{mtype} is a signed op again"
+
+
+@pytest.mark.parametrize("mtype", GONE)
+def test_an_older_client_asking_is_issued_nothing_to_sign(mtype):
+ """A 5.x client still sends these. Nothing it sends may come back as a
+ challenge — a challenge is what a compromised page needs signed."""
+ ctx = {"sk_node": Ed25519PrivateKey.from_private_bytes(b"\x01" * 32),
+ "groups": {GROUP: {}}, "has_admin_authority": True}
+ s = WebRTCPeerSession(_PC(), ctx, peer_id="peer")
+ s._channel = _Channel()
+ s._audit = lambda *a, **k: None
+ s._user_id, s._group_id = "operator", GROUP
+
+ s._dispatch_message({"type": mtype, "group_id": GROUP, "path": "/home/someone",
+ "shared_dir": "/home/someone", "name": "x",
+ "root_name": "x", "writable": True})
+
+ assert s._admin_ops == {}
+ assert not [m for m in s._channel.sent if m.get("type") == "admin_challenge"]
+
+
+def _state(tmp_path: Path, indexer_roots: RootSet | None = None):
+ (tmp_path / "media").mkdir()
+ cfg = GroupConfig(id=GROUP, name="plop",
+ roots=[RootSpec(path=str(tmp_path / "media"), name="media")])
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ f'[[groups]]\nid = "{GROUP}"\nname = "plop"\n\n'
+ f' [[groups.roots]]\n path = "{(tmp_path / "media").as_posix()}"\n'
+ f' name = "media"\n', encoding="utf-8")
+ live = RootSet.build([asdict(r) for r in cfg.roots])
+ pushed: list[list[dict]] = []
+
+ async def on_change(indexer):
+ pushed.append(indexer.index.roots)
+
+ indexer = DirectoryIndexer(indexer_roots or live, GROUP,
+ Ed25519PrivateKey.generate(), None,
+ on_change=on_change)
+ state = {"config": SimpleNamespace(groups=[cfg]), "config_path": str(conf),
+ "groups_ctx": {GROUP: {"roots": live}},
+ "indexers": {GROUP: indexer}}
+ return state, pushed
+
+
+async def test_a_flag_changed_on_the_node_reaches_every_open_page(tmp_path):
+ state, pushed = _state(tmp_path)
+
+ await ops.update_root(state, GROUP, "media", writable=True)
+
+ assert pushed, "nothing was pushed — open pages keep the old flag"
+ assert pushed[-1][0]["writable"] is True
+
+
+async def test_the_pushed_table_is_right_when_the_indexer_holds_its_own_set(tmp_path):
+ """The indexer and the group context normally share one RootSet; when they
+ do not, the table peers receive is the indexer's, and must carry the flag."""
+ (tmp_path / "media").mkdir()
+ own = RootSet.build([{"path": str(tmp_path / "media"), "name": "media"}])
+ (tmp_path / "media").rmdir()
+ state, pushed = _state(tmp_path, indexer_roots=own)
+
+ await ops.update_root(state, GROUP, "media", removable=True)
+
+ assert pushed[-1][0]["removable"] is True
+ assert state["groups_ctx"][GROUP]["roots"].by_name("media").removable is True