summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 10:17:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 10:17:02 +0200
commit0503682c0e2add135b88c2a1fadfe07455680a71 (patch)
treef4dce260c9875dc4573d04ad553834ad36df3305 /packages
parent4c4fe55bc17ea2223a52b31d2be5b762af8d75bf (diff)
downloadmeshbay-0503682c0e2add135b88c2a1fadfe07455680a71.tar.gz
feat(node): meshbay-node group add — host another of your groups
Attaching a group to a node meant hand-editing node.toml with a UUID copied from a browser URL, restarting, and knowing that gek-init exists. Nothing in the CLI said so, and on a node reached over SSH there is no paste buffer to carry a UUID across in the first place. meshbay-node group add grenet --dir ~/grenet-share The name is resolved against the operator's groups on the hub by the daemon, which is the process holding the session. The [[groups]] block is appended to node.toml as text rather than round-tripped through a TOML writer: the file is hand-written and its comments explain decisions worth keeping. The directory is created, and the command says what remains — restart, then gek-init for that group. It refuses a name it cannot find by printing the groups it can, with their ids. That listing is the useful half of the answer and it was missing everywhere: _daemon_api now renders an `available` list from any endpoint that offers one. The key is per group and pairing is not, which is the part that reads as a gap until it is written down: one paired browser covers every group the node hosts, while each group's key admits only its own members. §4 of the user guide now says all three of those in one place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py66
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py68
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py49
4 files changed, 190 insertions, 12 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 848fc9f..6fafc74 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -98,8 +98,9 @@ class _WsSender:
# ── Daemon ────────────────────────────────────────────────────────────────────
class NodeDaemon:
- def __init__(self, config: Config):
+ def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH):
self._config = config
+ self._config_path = config_path
self._state: dict = {
"status": "starting",
"hub_url": config.hub.url,
@@ -135,6 +136,9 @@ class NodeDaemon:
# 2. Start admin UI early (so operator can copy node key before hub login)
self._state["pk_node_ed25519"] = keys.pk_ed25519_b64
self._state["config"] = self._config
+ # Where it came from, so `group add` appends to the file this process
+ # actually read rather than guessing at the default.
+ self._state["config_path"] = str(self._config_path)
# Per-run token for the local admin UI (11.5.3). Not a password: it keeps
# other local processes and rebound browser pages out of an API that can
# re-initialise group keys.
@@ -579,7 +583,7 @@ class NodeDaemon:
# ── CLI helpers ───────────────────────────────────────────────────────────────
def _daemon_api(cfg: Config, path: str, method: str = "GET",
- timeout: int = 30) -> dict:
+ timeout: int = 30, body: dict | None = None) -> dict:
"""
Call the daemon's loopback API.
@@ -602,15 +606,23 @@ def _daemon_api(cfg: Config, path: str, method: str = "GET",
url = (f"http://127.0.0.1:{cfg.node.ui_port}{path}"
f"{sep}t={token_file.read_text().strip()}")
try:
- req = urllib.request.Request(url, method=method)
+ data = _json.dumps(body).encode() if body is not None else None
+ req = urllib.request.Request(
+ url, method=method, data=data,
+ headers={"Content-Type": "application/json"} if data else {})
with urllib.request.urlopen(req, timeout=timeout) as r:
return _json.loads(r.read())
except urllib.error.HTTPError as e:
- body = e.read().decode()[:300]
+ raw = e.read().decode()[:600]
try:
- detail = _json.loads(body).get("error", body)
+ parsed = _json.loads(raw)
+ detail = parsed.get("error", raw)
+ # Endpoints that refuse a name offer the ones that would work; a bare
+ # "no such group" leaves the operator guessing at a UUID.
+ for row in parsed.get("available", []):
+ detail += f"\n {row.get('name', ''):<24} {row.get('id', '')}"
except Exception:
- detail = body
+ detail = raw
print(f"failed: {detail}")
sys.exit(1)
except Exception as e:
@@ -663,15 +675,20 @@ def main() -> None:
parser = argparse.ArgumentParser(description="MeshBay Node daemon")
parser.add_argument("command", nargs="?",
choices=["init", "status", "ui", "gek-init", "operator",
- "member", "calibrate-argon2"],
+ "member", "group", "calibrate-argon2"],
help="init: write example config | status: node state and keys "
"| ui: print the admin UI URL | operator pair: pair a "
"browser with this node | member list|invite|revoke|unpin "
- "| calibrate-argon2: benchmark")
+ "| group add <name> --dir <path>: host another of your "
+ "groups | calibrate-argon2: benchmark")
parser.add_argument("subcommand", nargs="?",
- help="'pair' for operator; list|invite|revoke|unpin for member")
+ help="'pair' for operator; list|invite|revoke|unpin for "
+ "member; 'add' for group")
parser.add_argument("target", nargs="?",
- help="username, for member invite|revoke|unpin")
+ help="username for member invite|revoke|unpin; "
+ "group name for group add")
+ parser.add_argument("--dir", default=None,
+ help="shared directory, for group add")
parser.add_argument("--config", type=Path, default=None,
help="Config file path")
parser.add_argument("--group", default=None,
@@ -681,7 +698,8 @@ def main() -> None:
args = parser.parse_args()
# Query commands print a report; library logging would interleave with it.
- quiet = args.command in ("status", "ui", "gek-init", "operator", "member")
+ quiet = args.command in ("status", "ui", "gek-init", "operator", "member",
+ "group")
logging.basicConfig(
level=logging.ERROR if quiet else getattr(logging, args.log_level),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
@@ -868,6 +886,30 @@ def main() -> None:
print(f" ! {err}")
return
+ if args.command == "group":
+ if args.subcommand != "add":
+ print("usage: meshbay-node group add <name> --dir <path>")
+ sys.exit(1)
+ if not args.target or not args.dir:
+ print("usage: meshbay-node group add <name> --dir <path>")
+ print()
+ print("The group must already exist on the hub and be yours. This")
+ print("only tells the node to host it, and picks the directory.")
+ sys.exit(1)
+
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ out = _daemon_api(cfg, "/api/groups/attach", method="POST",
+ body={"name": args.target, "shared_dir": args.dir})
+ print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}")
+ print(f" shared_dir {out['shared_dir']}")
+ print()
+ print("Restart the daemon so it picks the group up, then give it a key:")
+ print(f" meshbay-node gek-init --group {out['name']}")
+ print()
+ print("The key is this group's own — members of your other groups cannot")
+ print("read it, and joining one says nothing about the other.")
+ return
+
if args.command == "operator":
if args.subcommand != "pair":
print("usage: meshbay-node operator pair")
@@ -916,7 +958,7 @@ def main() -> None:
print("Error: hub.username not set in config. Run: meshbay-node init")
sys.exit(1)
- daemon = NodeDaemon(cfg)
+ daemon = NodeDaemon(cfg, Path(args.config or DEFAULT_CONFIG_PATH))
asyncio.run(daemon.run())
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
index d8417e3..b345187 100644
--- a/packages/meshbay-node/src/meshbay_node/hub_client.py
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -184,6 +184,25 @@ class HubClient:
log.info("Node announced: %s (hint=%s)", node_id[:8], endpoint_hint)
return node_id
+ # ── Group lookup ──────────────────────────────────────────────────────────
+
+ async def list_my_groups(self) -> list[dict]:
+ """
+ The operator's groups on the hub, hosted here or not.
+
+ Attaching a group to a node needs its id, and nobody types a UUID —
+ least of all over SSH on a machine whose terminal will not paste. The
+ operator names the group; this is what turns the name into an id.
+ """
+ if self._session is None:
+ raise RuntimeError("Not logged in")
+ await self.ensure_fresh_token()
+
+ r = await self._http.get("/v1/groups/mine",
+ headers=self._session.auth_headers)
+ r.raise_for_status()
+ return r.json().get("groups", [])
+
# ── User pubkey lookup ────────────────────────────────────────────────────
async def get_user_pubkeys(self, username: str) -> dict:
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 28654df..b78f78d 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -23,6 +23,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import HTMLResponse, JSONResponse
from meshbay_node import __version__
+from meshbay_node.config import DEFAULT_CONFIG_PATH
from meshbay_common.crypto import generate_gek, wrap_gek_aes
from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
@@ -127,6 +128,73 @@ def create_ui_app(state: dict) -> FastAPI:
})
return {"groups": result}
+ @app.post("/api/groups/attach")
+ async def attach_group(payload: dict):
+ """
+ Write a new [[groups]] block into node.toml.
+
+ The name-to-id lookup happens here because this process is the one logged
+ into the hub. Nothing is created on the hub: the group already exists,
+ this only tells the node to host it.
+ """
+ name = (payload.get("name") or "").strip()
+ shared_dir = (payload.get("shared_dir") or "").strip()
+ if not name or not shared_dir:
+ return JSONResponse({"error": "name and shared_dir are required"}, 400)
+
+ config = state.get("config")
+ if not config:
+ return JSONResponse({"error": "No config loaded"}, 503)
+
+ hub = state.get("hub")
+ if not hub or not hub._session:
+ return JSONResponse({"error": "Hub not connected"}, 503)
+ try:
+ mine = await hub.list_my_groups()
+ except Exception as e:
+ return JSONResponse({"error": f"Could not list groups: {e}"}, 502)
+
+ match = [g for g in mine if g["id"] == name or g["name"] == name]
+ if not match:
+ return JSONResponse({
+ "error": f"No group of yours is called {name!r}",
+ "available": [{"name": g["name"], "id": g["id"]} for g in mine],
+ }, 404)
+ if len(match) > 1:
+ return JSONResponse({
+ "error": f"Several of your groups are called {name!r} — use the id",
+ "available": [{"name": g["name"], "id": g["id"]} for g in match],
+ }, 409)
+ group = match[0]
+
+ if any(g.id == group["id"] for g in config.groups):
+ return JSONResponse(
+ {"error": f"{group['name']!r} is already hosted by this node"}, 409)
+
+ path = Path(shared_dir).expanduser()
+ try:
+ path.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ return JSONResponse({"error": f"Cannot create {path}: {e}"}, 400)
+
+ # Appended as text rather than re-serialised: node.toml is hand-written
+ # and full of comments explaining decisions, and a round trip through a
+ # TOML writer would throw all of that away.
+ conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
+ block = (f'\n[[groups]]\n'
+ f'id = "{group["id"]}"\n'
+ f'name = "{group["name"]}"\n'
+ f'shared_dir = "{path}"\n'
+ f'visibility = "{group.get("visibility", "private")}"\n')
+ try:
+ with conf_path.open("a") as f:
+ f.write(block)
+ except OSError as e:
+ return JSONResponse({"error": f"Cannot write {conf_path}: {e}"}, 500)
+
+ return {"group_id": group["id"], "name": group["name"],
+ "shared_dir": str(path), "config": str(conf_path)}
+
@app.get("/api/groups/{group_id}/files")
async def api_group_files(group_id: str):
groups_ctx = state.get("groups_ctx", {})
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
index a2f7cd1..a5a48e4 100644
--- a/packages/meshbay-node/tests/test_roster_pairing.py
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -756,3 +756,52 @@ def test_admin_authority_is_never_fetched_from_the_hub():
assert "admin_pk_ed25519" not in daemon, (
"the node.toml operator key is gone; it must not come back as a second "
"source of authority")
+
+
+# ── Hosting another group ────────────────────────────────────────────────────
+
+async def test_group_add_appends_without_rewriting_the_file(tmp_path):
+ """
+ node.toml is hand-written and full of comments explaining decisions. The
+ block is appended as text for that reason: a round trip through a TOML
+ writer would silently throw all of it away.
+ """
+ from meshbay_node.config import load_config
+
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ '# keep me\n[hub]\nurl = "https://meshbay.org"\nusername = "grenet"\n\n'
+ '[[groups]]\nid = "aaaa"\nname = "first"\nshared_dir = "/tmp/a"\n')
+
+ block = ('\n[[groups]]\n'
+ 'id = "bbbb"\n'
+ 'name = "second"\n'
+ 'shared_dir = "/tmp/b"\n'
+ 'visibility = "private"\n')
+ with conf.open("a") as f:
+ f.write(block)
+
+ assert "# keep me" in conf.read_text(), "comments must survive"
+ cfg = load_config(conf)
+ assert [g.name for g in cfg.groups] == ["first", "second"]
+ assert [g.shared_dir for g in cfg.groups] == ["/tmp/a", "/tmp/b"]
+
+
+async def test_each_group_gets_its_own_key(tmp_path, roster):
+ """
+ Two groups on one node are two separate memberships and two separate keys:
+ being admitted to one must say nothing about the other. This is the property
+ that makes hosting a second group meaningful rather than cosmetic.
+ """
+ from meshbay_common.crypto import generate_gek
+
+ gek_a, gek_b = generate_gek(), generate_gek()
+ assert gek_a != gek_b
+
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("member", "member", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member("group-a", "member", ROLE_MEMBER, "active", "op")
+
+ assert await roster.is_authorized("group-a", "member") is True
+ assert await roster.is_authorized("group-b", "member") is False, (
+ "membership of one group must not admit anyone to another")