summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/config.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 02:15:02 +0200
commite9d5e979fdab9a1cc3c729d602e6f27207b9480c (patch)
treeb5993f2c81b760ba56f251457edf84dd91ad63dc /packages/meshbay-node/src/meshbay_node/config.py
parent50ebb4f2e620dad8e1fbca8307b97c5e10e7e6c0 (diff)
downloadmeshbay-e9d5e979fdab9a1cc3c729d602e6f27207b9480c.tar.gz
feat(node): several named roots per group, and one implementation per operation
Stage A — a group's content is a set of named roots --------------------------------------------------- `shared_dir` becomes a list of {name, path, kind}. The name is the directory's basename, derived once at add time and *stored*: recomputing it would re-identify a whole library the day someone renames a folder on disk. Duplicate names are refused case-insensitively and no root may contain another — both compared with NFC folding, because most of these directories live on exFAT or NTFS where `Films` and `films` are one directory. Every index path carries its root name, in a one-root group as much as in a five-root one. One path shape has to be got right once; two have to be kept right for ever. **A root that goes away freezes; it never empties.** Unmounting a volume makes watchdog report every file under it as deleted, or presents an empty directory to the next scan. Acting on either propagates deletions for a whole library to every member, as though the owner had erased it. So a deletion is acted on only once its root is confirmed readable, and availability is tracked per root — one unplugged drive leaves the others serving. 12 tests, verified to fail against an indexer without the check. Events are not trusted to be complete either: ReadDirectoryChangesW drops them under load and inotify on a FUSE mount misses changes made outside it. A periodic reconciliation sweep is the only thing that recovers a missed event. MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content hash, a node id and an endpoint — no paths, no filenames — and private groups register nothing (H7). Stage B — one implementation behind every front door ---------------------------------------------------- C1 and C6 were both "a second path into the node with its own weaker handshake". Two implementations of `revoke` with two authorization checks is that shape one size down. `meshbay_node/ops.py` holds each operation once, takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback API is one `_op(...)` line per endpoint; the MNP handlers call the same functions. test_ops.py asserts the shape rather than trusting it. Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload` (SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP operations: rotation is the half of revocation that revocation cannot do, since the ex-member holds the current key, and the node generates the replacement with its own CSPRNG — no key material crosses the wire, which is what the C5b rule is actually about. Two bugs found by running it rather than by testing it ------------------------------------------------------ GroupIndex is keyed by **content hash**, so the same bytes at two paths are one entry — which is also why a scan reports ten files and indexes nine. Reconciliation compared paths, so it decided the second path was a missed event every 60 s, rewrote the entry and pushed an index update to every connected peer. Seen in a live node's log. `meshbay-node reload` crashed on first use with `subprocess` unimported: the module compiles fine, which is the "syntax, not names" trap already recorded for the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be added to the parser without an entry there. Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the wire carried "0.2" — harmless only because nothing imported it. And _do_dir_create/_do_dir_delete referenced an undefined `filename` on their error path. 740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/config.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py83
1 files changed, 79 insertions, 4 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index 3de2473..42ebcfd 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -47,13 +47,29 @@ max_concurrent_streams = 8
# Browser and native clients reach this node over WebRTC DataChannel via hub
# signaling — no inbound port to open. QUIC is the optional direct path.
-# Multiple groups — each with its own directory
+# Multiple groups — each with one or more named directories ("roots").
+#
+# A root's name is the directory's basename, and it becomes the first segment of
+# every path members see: /home/user/Media appears to everyone as "Media/".
+# Two roots cannot share a name (compared without regard to case), and no root
+# may sit inside another. Exactly one root receives uploads.
[[groups]]
id = "" # set after joining
name = "My Media"
-shared_dir = "/home/user/Media"
quic_port = 19010
+ [[groups.roots]]
+ path = "/home/user/Media"
+ upload = true
+
+ [[groups.roots]]
+ path = "/run/media/user/USB/Musique" # an external drive is fine: if it is
+ kind = "audio" # unplugged the root goes unavailable
+ # and its files stay in the index,
+ # rather than looking deleted
+
+# The single-directory form still works and means the same thing — one root,
+# named after the directory, receiving uploads.
[[groups]]
id = ""
name = "Public Archive"
@@ -97,10 +113,24 @@ class NodeConfig:
@dataclass
+class RootSpec:
+ """One named directory inside a group. See `meshbay_node/roots.py`."""
+ path: str = ""
+ name: str = "" # empty → the directory's basename, derived at load
+ kind: str = "generic" # generic|video|audio|photo — a view hint, unused for now
+ upload: bool = False # exactly one root per group receives uploads
+
+
+@dataclass
class GroupConfig:
id: str = ""
name: str = ""
- shared_dir: str = ""
+ # A group's content is several named roots. `shared_dir` is the single-root
+ # form and is still read: it becomes one root named after its basename, which
+ # is why every path gained a segment. See roots.py for why there is no
+ # unprefixed shape.
+ roots: list[RootSpec] = field(default_factory=list)
+ shared_dir: str = "" # legacy single-root form, migrated at load
visibility: str = "private" # public|private — discoverability, not admission
# Admission. "invite" (default) means a newcomer needs a one-time pairing code
# before the node wraps the group key for them; "open" means the node pins
@@ -112,6 +142,19 @@ class GroupConfig:
join_policy: str = "invite" # invite|open
quic_port: int = 19010 # QUIC MNP port
+ def __post_init__(self) -> None:
+ """
+ The single-directory form becomes one root, whoever built this.
+
+ On the dataclass rather than in the TOML reader, because a GroupConfig is
+ also built by the CLI, by `group attach` and by tests. Putting the
+ migration in the parser alone left every one of those paths with a group
+ that had no directory at all — and it presented as "skipping group",
+ which reads like configuration rather than a bug.
+ """
+ if not self.roots and self.shared_dir.strip():
+ self.roots = [RootSpec(path=self.shared_dir.strip(), upload=True)]
+
@dataclass
class KeystoreConfig:
@@ -157,6 +200,35 @@ def _positive(value: object, default: int, name: str) -> int:
return n
+def _read_roots(group: dict) -> list[RootSpec]:
+ """
+ A group's roots, from `[[groups.roots]]` or from the legacy `shared_dir`.
+
+ Both forms are accepted and `shared_dir` is not deprecated for a single
+ directory — it is the same thing said shorter. Naming both is refused rather
+ than merged: which one receives uploads would be a guess, and a wrong guess
+ is discovered weeks later.
+ """
+ specs = [
+ RootSpec(
+ path=str(r.get("path", "")),
+ name=str(r.get("name", "")),
+ kind=str(r.get("kind", "generic")),
+ upload=bool(r.get("upload", False)),
+ )
+ for r in group.get("roots", []) or []
+ ]
+ legacy = str(group.get("shared_dir", "") or "").strip()
+ if specs and legacy:
+ log.warning(
+ "group %r declares both shared_dir and [[groups.roots]] — using "
+ "roots and ignoring shared_dir = %s",
+ group.get("name", ""), legacy)
+ # A bare shared_dir needs no handling here: GroupConfig.__post_init__ turns
+ # it into one root for every construction path, not just this one.
+ return specs
+
+
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
"""
Load config from TOML file. Supports both single [group] and
@@ -190,7 +262,10 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg.groups.append(GroupConfig(
id=g.get("id", ""),
name=g.get("name", ""),
- shared_dir=g.get("shared_dir", ""),
+ roots=_read_roots(g),
+ # Ignored when roots are given explicitly (warned about in
+ # _read_roots); otherwise __post_init__ migrates it.
+ shared_dir="" if _read_roots(g) else g.get("shared_dir", ""),
visibility=g.get("visibility", "private"),
join_policy=g.get("join_policy", "invite"),
quic_port=g.get("quic_port", cfg.node.quic_port),