aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-01 09:46:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-01 09:46:39 +0200
commit0d0898c656afb8c1faa9fa91ba525e8a3e6a34ee (patch)
treea74f7d7e651b981f84421f38f53d88084a544138
parent5b0cd92012bb162f6290fbfb97938f41cad81b7a (diff)
downloadmeshbay-0d0898c656afb8c1faa9fa91ba525e8a3e6a34ee.tar.gz
fix(node): how a hosted group admits people is the operator's, not the hub's
attach_group no longer copies join_policy and visibility from the hub's answer: they come with the operator's request (the desktop creation form, `group add --open`) and default to invite/private; the CLI says when the hub lists the group otherwise. Every string written into node.toml is escaped (toml_string) and read back through tomllib, so a group or folder name cannot write lines of its own (F-17). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md6
-rw-r--r--docs/QUICKSTART.md2
-rw-r--r--packages/meshbay-client/src/main.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js3
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/groups.py11
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/parser.py3
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/groups.py30
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/node_toml.py59
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/roots.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py1
-rw-r--r--packages/meshbay-node/tests/golden/cli.json9
-rw-r--r--packages/meshbay-node/tests/test_attach_from_the_hub.py99
-rw-r--r--packages/meshbay-node/tests/test_ops.py2
13 files changed, 209 insertions, 34 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 2966e5e..38977af 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -544,6 +544,12 @@ admission. Only the second decides whether a code is required: a public group wi
**`join_policy` is read from `node.toml`, never from the hub.** A hub able to
declare a group open would be handed its key. An unknown group reads as `invite`.
+It is written there when the node starts hosting the group, from the operator's
+own request — the creation form in the desktop application, `group add --open` on
+the command line — and is `invite` unless that request says `open`; the hub's
+record of the group is looked up for its id and name only. Every string written
+into `node.toml` is escaped as a TOML string (`ops.node_toml.toml_string`): a group
+or folder name is someone else's text.
### 3.6 Passphrase change and recovery
diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md
index a11bb00..ce1eec1 100644
--- a/docs/QUICKSTART.md
+++ b/docs/QUICKSTART.md
@@ -235,7 +235,7 @@ What those three did:
| | |
|---|---|
-| `group add` | told the node to host that group, and made the directory its first shared folder. It is **read-write** by default, so members can upload into it. Add `--no-writable` if you want a published, read-only library. |
+| `group add` | told the node to host that group, and made the directory its first shared folder. It is **read-write** by default, so members can upload into it. Add `--no-writable` if you want a published, read-only library. It admits members **by invitation**; add `--open` to let anyone the hub lists the group to join. |
| `reload` | made the running daemon re-read its config without dropping anyone. |
| `gek init` | generated the group's encryption key. **Nothing works before this** — the key never leaves your node, and every member receives it wrapped for their own key, on every connection. |
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 309d5f6..236a285 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -2184,7 +2184,10 @@ function registerBridge() {
attachGroup: async (a) => {
const body = { name: aText(a.name, 'the group name'),
shared_dir: aText(a.path, 'the folder', 4096),
- writable: a.writable !== false };
+ writable: a.writable !== false,
+ // The person's choice on the creation form; the node never
+ // takes it from the hub.
+ join_policy: a.joinPolicy === 'open' ? 'open' : 'invite' };
await confirmOrRefuse('native.attach_confirm',
{ name: body.name, path: body.shared_dir });
return ['POST', '/api/groups/attach', body];
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index d4c2ab8..bdb3dbc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -255,6 +255,9 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl
name: name.trim(),
path: mainRoot.path,
writable: mainRoot.writable !== false,
+ // How people join is set on the node, from this form — the node does
+ // not take it from the hub.
+ joinPolicy,
};
await platform.node.op('attachGroup', attachBody);
await platform.node.op('reload');
diff --git a/packages/meshbay-node/src/meshbay_node/cli/groups.py b/packages/meshbay-node/src/meshbay_node/cli/groups.py
index 3fab4b9..fca4800 100644
--- a/packages/meshbay-node/src/meshbay_node/cli/groups.py
+++ b/packages/meshbay-node/src/meshbay_node/cli/groups.py
@@ -64,7 +64,7 @@ def group(args) -> None:
sys.exit(1)
if not args.target or not args.dir:
print("usage: meshbay-node group add <name> --dir <path> "
- "[--no-writable]")
+ "[--no-writable] [--open]")
print()
print("The group must already exist on the hub and be yours. This")
print("only tells the node to host it, and picks its first")
@@ -78,12 +78,19 @@ def group(args) -> None:
# is not a working group. Every root added *later* is read-only by
# default, which is the opposite rule and the right one there.
writable = args.writable is not False
+ # How people join is the operator's to say here, never read from the hub.
+ join_policy = "open" if getattr(args, "open", False) else "invite"
body = {"name": args.target, "shared_dir": args.dir,
- "writable": writable}
+ "writable": writable, "join_policy": join_policy}
out = _daemon_api(cfg, "/api/groups/attach", method="POST", body=body)
print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}")
print(f" shared_dir {out['shared_dir']}"
f" ({'read-write' if writable else 'read-only'})")
+ print(f" join_policy {join_policy}")
+ if out.get("hub_join_policy") == "open" and join_policy != "open":
+ print()
+ print("The hub lists this group as open; this node admits by invitation")
+ print("only. To host it open, remove it and add it again with --open.")
print()
print("Tell the daemon to re-read its config, then give the group a key:")
print(" meshbay-node reload")
diff --git a/packages/meshbay-node/src/meshbay_node/cli/parser.py b/packages/meshbay-node/src/meshbay_node/cli/parser.py
index 29a9f63..cfc4704 100644
--- a/packages/meshbay-node/src/meshbay_node/cli/parser.py
+++ b/packages/meshbay-node/src/meshbay_node/cli/parser.py
@@ -87,6 +87,9 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--no-removable", action="store_false",
dest="removable",
help="mark root as not removable (root set)")
+ parser.add_argument("--open", action="store_true",
+ help="group add: anyone the hub lists the group to may join "
+ "(default: by invitation only)")
parser.add_argument("--name", default=None,
help="root name (root add; defaults to directory basename)")
parser.add_argument("--log-level", default="INFO",
diff --git a/packages/meshbay-node/src/meshbay_node/ops/groups.py b/packages/meshbay-node/src/meshbay_node/ops/groups.py
index 1d8003c..ac14c3a 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/groups.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/groups.py
@@ -9,7 +9,7 @@ from meshbay_common.crypto import generate_gek, wrap_gek_aes
from meshbay_node.config import DEFAULT_CONFIG_PATH
from meshbay_node.ops.core import OpError, _config, _group_ctx, _hub
-from meshbay_node.ops.node_toml import _find_group_range
+from meshbay_node.ops.node_toml import _find_group_range, toml_string
log = logging.getLogger("meshbay_node.ops")
@@ -142,17 +142,28 @@ async def list_groups(state: dict) -> dict:
return {"groups": out, "operator_paired": has_operator, "settings": settings}
+JOIN_POLICIES = ("invite", "open")
+
+
async def attach_group(state: dict, name: str, shared_dir: str,
- writable: bool = True) -> dict:
+ writable: bool = True, join_policy: str = "invite") -> 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.
+
+ `join_policy` is the operator's, given with this request, and `invite`
+ unless they say otherwise. The hub's own record of the group is not read
+ for it: a hub that could declare a group open would be handed its key by
+ anyone it sent. The hub's value is returned beside it, so a caller can say
+ when the two differ.
"""
if not name or not shared_dir:
raise OpError("name and shared_dir are required")
+ if join_policy not in JOIN_POLICIES:
+ raise OpError(f"join_policy must be one of {', '.join(JOIN_POLICIES)}")
config = _config(state)
hub = _hub(state)
try:
@@ -182,12 +193,12 @@ async def attach_group(state: dict, name: str, shared_dir: str,
raise OpError(f"Cannot create {path}: {e}") from e
conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
- join_policy = group.get("join_policy", "invite")
+ visibility = "public" if join_policy == "open" else "private"
block = (f'\n[[groups]]\n'
- f'id = "{group["id"]}"\n'
- f'name = "{group["name"]}"\n'
- f'visibility = "{group.get("visibility", "private")}"\n'
- f'join_policy = "{join_policy}"\n')
+ f'id = {toml_string(group["id"])}\n'
+ f'name = {toml_string(group["name"])}\n'
+ f'visibility = {toml_string(visibility)}\n'
+ f'join_policy = {toml_string(join_policy)}\n')
# No `upload_dir` here. `GroupConfig.__post_init__` still *reads* it, so an
# existing node.toml keeps working — but what it does on read is force every
# other root read-only and append that path as the one writable one, which
@@ -198,7 +209,7 @@ async def attach_group(state: dict, name: str, shared_dir: str,
block += (f'\n [[groups.roots]]\n'
# Forward slashes: a Windows path in a TOML basic string is a
# parse error (`\U`, `\a`, ... are escapes). pathlib reads `/`.
- f' path = "{path.as_posix()}"\n'
+ f' path = {toml_string(path.as_posix())}\n'
f' writable = {"true" if writable else "false"}\n')
try:
with conf_path.open("a", encoding="utf-8", newline="\n") as f:
@@ -208,7 +219,8 @@ async def attach_group(state: dict, name: str, shared_dir: str,
result = {"group_id": group["id"], "name": group["name"],
"shared_dir": str(path), "config": str(conf_path),
- "writable": writable,
+ "writable": writable, "join_policy": join_policy,
+ "hub_join_policy": group.get("join_policy", "invite"),
"note": "restart the node to pick it up"}
return result
diff --git a/packages/meshbay-node/src/meshbay_node/ops/node_toml.py b/packages/meshbay-node/src/meshbay_node/ops/node_toml.py
index f711f26..2407722 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/node_toml.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/node_toml.py
@@ -3,14 +3,50 @@
from __future__ import annotations
import re
+import tomllib
from pathlib import Path
from meshbay_node.ops.core import OpError
+def toml_string(value: str) -> str:
+ """A TOML basic string holding `value` exactly, quotes included.
+
+ Every string written into node.toml goes through here. A value with a quote
+ or a newline in it — a group name, a folder name, any of them chosen by
+ someone else — would otherwise end the string and write lines of its own.
+ """
+ out = ['"']
+ for ch in str(value):
+ if ch == '"':
+ out.append('\\"')
+ elif ch == "\\":
+ out.append("\\\\")
+ elif ord(ch) < 0x20 or ord(ch) == 0x7F:
+ out.append(f"\\u{ord(ch):04x}")
+ else:
+ out.append(ch)
+ out.append('"')
+ return "".join(out)
+
+
+def _string_value(line: str, key: str) -> str | None:
+ """The string `key` holds on this line, unescaped — or None.
+
+ Read as TOML, not by pattern: a value written by `toml_string` may carry an
+ escaped quote or backslash, which a `"([^"]*)"` pattern would cut short.
+ """
+ if not re.match(r"^\s*" + re.escape(key) + r"\s*=", line):
+ return None
+ try:
+ value = tomllib.loads(line.strip()).get(key)
+ except tomllib.TOMLDecodeError:
+ return None
+ return value if isinstance(value, str) else None
+
+
def _find_group_range(lines: list[str], group_id: str) -> tuple[int, int] | None:
"""Line range of a [[groups]] block by id: (start, end_exclusive)."""
- id_re = re.compile(r'^\s*id\s*=\s*"([^"]*)"')
block_starts: list[int] = []
for i, line in enumerate(lines):
if line.strip() == "[[groups]]":
@@ -24,8 +60,7 @@ def _find_group_range(lines: list[str], group_id: str) -> tuple[int, int] | None
boundary = k
break
for k in range(start + 1, boundary):
- m = id_re.match(lines[k])
- if m and m.group(1) == group_id:
+ if _string_value(lines[k], "id") == group_id:
return (start, boundary)
return None
@@ -61,8 +96,10 @@ def _update_node_toml(conf_path: Path, updates: dict) -> None:
if isinstance(value, bool):
return f"{key} = {'true' if value else 'false'}"
if isinstance(value, list):
- items = ", ".join(f'"{v}"' for v in value)
+ items = ", ".join(toml_string(v) for v in value)
return f"{key} = [{items}]"
+ if isinstance(value, str):
+ return f"{key} = {toml_string(value)}"
return f"{key} = {value}"
remaining = dict(updates)
@@ -115,7 +152,6 @@ def _remove_roots_block(conf_path: Path, group_id: str,
raise OpError(f"Group {group_id[:8]} not found in {conf_path}")
start, end = rng
- path_re = re.compile(r'^\s*path\s*=\s*"([^"]*)"')
roots_starts: list[int] = []
for i in range(start + 1, end):
if lines[i].strip() == "[[groups.roots]]":
@@ -124,10 +160,10 @@ def _remove_roots_block(conf_path: Path, group_id: str,
for j, rs in enumerate(roots_starts):
rs_end = roots_starts[j + 1] if j + 1 < len(roots_starts) else end
for k in range(rs, rs_end):
- m = path_re.match(lines[k])
- if m:
+ raw = _string_value(lines[k], "path")
+ if raw is not None:
try:
- p = str(Path(m.group(1)).expanduser().resolve())
+ p = str(Path(raw).expanduser().resolve())
except OSError:
continue
if p == resolved_path:
@@ -153,7 +189,6 @@ def _update_root_field(conf_path: Path, group_id: str,
raise OpError(f"Group {group_id[:8]} not found in {conf_path}")
start, end = rng
- path_re = re.compile(r'^\s*path\s*=\s*"([^"]*)"')
writable_re = re.compile(r'^\s*(writable|upload)\s*=')
removable_re = re.compile(r'^\s*removable\s*=')
roots_starts: list[int] = []
@@ -165,10 +200,10 @@ def _update_root_field(conf_path: Path, group_id: str,
rs_end = roots_starts[j + 1] if j + 1 < len(roots_starts) else end
found_path = False
for k in range(rs, rs_end):
- m = path_re.match(lines[k])
- if m:
+ raw = _string_value(lines[k], "path")
+ if raw is not None:
try:
- p = str(Path(m.group(1)).expanduser().resolve())
+ p = str(Path(raw).expanduser().resolve())
except OSError:
continue
if p == resolved_path:
diff --git a/packages/meshbay-node/src/meshbay_node/ops/roots.py b/packages/meshbay-node/src/meshbay_node/ops/roots.py
index e3e2781..480fe63 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/roots.py
@@ -8,7 +8,12 @@ from pathlib import Path
from meshbay_node.config import DEFAULT_CONFIG_PATH
from meshbay_node.ops.core import OpError, _config, _group_ctx, _roster
-from meshbay_node.ops.node_toml import _insert_roots_block, _remove_roots_block, _update_root_field
+from meshbay_node.ops.node_toml import (
+ _insert_roots_block,
+ _remove_roots_block,
+ _update_root_field,
+ toml_string,
+)
from meshbay_node.roots import RootError, RootSet, off_disk
log = logging.getLogger("meshbay_node.ops")
@@ -46,11 +51,11 @@ async def add_root(state: dict, group_id: str, path: str, *,
raise OpError(f"Cannot create {added.path}: {e}") from e
conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH)
- root_block = f' [[groups.roots]]\n path = "{added.path.as_posix()}"'
+ root_block = f' [[groups.roots]]\n path = {toml_string(added.path.as_posix())}'
if name:
- root_block += f'\n name = "{added.name}"'
+ root_block += f'\n name = {toml_string(added.name)}'
if kind != "generic":
- root_block += f'\n kind = "{added.kind}"'
+ root_block += f'\n kind = {toml_string(added.kind)}'
if writable:
root_block += '\n writable = true'
if removable:
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index bbc4649..f810903 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -179,6 +179,7 @@ def create_ui_app(state: dict) -> FastAPI:
(payload.get("name") or "").strip(),
(payload.get("shared_dir") or "").strip(),
writable=bool(payload.get("writable", True)),
+ join_policy=str(payload.get("join_policy") or "invite"),
))
reload_fn = state.get("reload_fn")
if reload_fn:
diff --git a/packages/meshbay-node/tests/golden/cli.json b/packages/meshbay-node/tests/golden/cli.json
index b397eeb..48d2f74 100644
--- a/packages/meshbay-node/tests/golden/cli.json
+++ b/packages/meshbay-node/tests/golden/cli.json
@@ -4,7 +4,7 @@
"asked": [],
"exit": 0,
"stderr": "",
- "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an optional e-mail label with\n --link, a link id for member cancel); group name for group add; file id\n for file rm; identifier for denylist clear; download cap for transfers\n set; size in GB for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link, for someone who may have no account yet\n (valid 7 days, single use)\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n",
+ "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--open] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an optional e-mail label with\n --link, a link id for member cancel); group name for group add; file id\n for file rm; identifier for denylist clear; download cap for transfers\n set; size in GB for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link, for someone who may have no account yet\n (valid 7 days, single use)\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --open group add: anyone the hub lists the group to may join (default: by\n invitation only)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n",
"systemctl": []
},
"autostart no-such-sub": {
@@ -266,7 +266,7 @@
"asked": [],
"exit": 1,
"stderr": "",
- "stdout": "usage: meshbay-node group add <name> --dir <path> [--no-writable]\n\nThe group must already exist on the hub and be yours. This\nonly tells the node to host it, and picks its first\ndirectory, which accepts uploads unless --no-writable.\nAdd more with: meshbay-node root add <path> [--writable]\n",
+ "stdout": "usage: meshbay-node group add <name> --dir <path> [--no-writable] [--open]\n\nThe group must already exist on the hub and be yours. This\nonly tells the node to host it, and picks its first\ndirectory, which accepts uploads unless --no-writable.\nAdd more with: meshbay-node root add <path> [--writable]\n",
"systemctl": []
},
"group add g --dir /tmp/media --no-writable": {
@@ -275,6 +275,7 @@
"POST",
"/api/groups/attach",
{
+ "join_policy": "invite",
"name": "g",
"shared_dir": "/tmp/media",
"writable": false
@@ -284,7 +285,7 @@
"asked": [],
"exit": 0,
"stderr": "",
- "stdout": "g (g) added to <tmp>/node.toml\n shared_dir <tmp> (read-only)\n\nTell the daemon to re-read its config, then give the group a key:\n meshbay-node reload\n meshbay-node gek init --group g\n\nThe key is this group's own — members of your other groups cannot\nread it, and joining one says nothing about the other.\n",
+ "stdout": "g (g) added to <tmp>/node.toml\n shared_dir <tmp> (read-only)\n join_policy invite\n\nTell the daemon to re-read its config, then give the group a key:\n meshbay-node reload\n meshbay-node gek init --group g\n\nThe key is this group's own — members of your other groups cannot\nread it, and joining one says nothing about the other.\n",
"systemctl": []
},
"group list": {
@@ -423,7 +424,7 @@
"api": [],
"asked": [],
"exit": 2,
- "stderr": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\nmeshbay-node: error: argument command: invalid choice: 'no-such-verb' (choose from init, reset, status, gek-init, gek, operator, member, group, root, file, video, chat, denylist, stun, transfers, reload, restart-daemon, autostart, service, calibrate-argon2)\n",
+ "stderr": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--open] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\nmeshbay-node: error: argument command: invalid choice: 'no-such-verb' (choose from init, reset, status, gek-init, gek, operator, member, group, root, file, video, chat, denylist, stun, transfers, reload, restart-daemon, autostart, service, calibrate-argon2)\n",
"stdout": "",
"systemctl": []
},
diff --git a/packages/meshbay-node/tests/test_attach_from_the_hub.py b/packages/meshbay-node/tests/test_attach_from_the_hub.py
new file mode 100644
index 0000000..f4aaa25
--- /dev/null
+++ b/packages/meshbay-node/tests/test_attach_from_the_hub.py
@@ -0,0 +1,99 @@
+"""
+Hosting a group writes what the operator said, never what the hub says.
+
+`attach_group` looks the group up on the hub, because the node is the process
+signed in there. What it must not take from that answer is how people join: a
+hub able to declare a group open would be handed its key by anyone it sent
+(admission in `transport/webrtc/admission.py` admits a stranger to an open
+group). And every string it writes into node.toml is someone else's text — a
+group name chosen on the hub, a folder name — so none of it may end a TOML
+string and write lines of its own.
+"""
+
+import tomllib
+from pathlib import Path
+
+import pytest
+from meshbay_node import ops
+from meshbay_node.config import load_config
+from meshbay_node.ops.node_toml import toml_string
+
+GID = "0f8fad5b-d9cb-469f-a165-70867728950e"
+HOSTILE = 'Films"\n[node]\nui_port = 1\n# '
+
+
+class _Hub:
+ _session = object() # signed in
+
+ def __init__(self, group):
+ self._group = group
+
+ async def list_my_groups(self):
+ return [self._group]
+
+
+def _state(tmp_path: Path, group: dict) -> dict:
+ conf = tmp_path / "node.toml"
+ conf.write_text('[hub]\nurl = "https://hub.invalid"\nusername = "op"\n\n'
+ '[node]\nui_port = 18000\n', encoding="utf-8")
+ return {"config": load_config(conf), "config_path": str(conf), "hub": _Hub(group)}
+
+
+def _hosted(tmp_path: Path) -> dict:
+ parsed = tomllib.loads((tmp_path / "node.toml").read_text(encoding="utf-8"))
+ return parsed["groups"][0] | {"node": parsed["node"]}
+
+
+async def test_a_group_the_hub_calls_open_is_hosted_by_invitation(tmp_path):
+ state = _state(tmp_path, {"id": GID, "name": "Films", "visibility": "public",
+ "join_policy": "open"})
+ out = await ops.attach_group(state, "Films", str(tmp_path / "share"))
+
+ hosted = _hosted(tmp_path)
+ assert hosted["join_policy"] == "invite"
+ assert hosted["visibility"] == "private"
+ assert out["hub_join_policy"] == "open", "the caller is not told the two differ"
+
+
+async def test_the_operator_opens_it(tmp_path):
+ state = _state(tmp_path, {"id": GID, "name": "Films", "join_policy": "invite"})
+ await ops.attach_group(state, "Films", str(tmp_path / "share"), join_policy="open")
+
+ hosted = _hosted(tmp_path)
+ assert (hosted["join_policy"], hosted["visibility"]) == ("open", "public")
+
+
+async def test_an_unknown_policy_is_refused(tmp_path):
+ state = _state(tmp_path, {"id": GID, "name": "Films"})
+ with pytest.raises(ops.OpError):
+ await ops.attach_group(state, "Films", str(tmp_path / "share"),
+ join_policy="anyone")
+
+
+async def test_a_group_name_cannot_write_lines_into_node_toml(tmp_path):
+ state = _state(tmp_path, {"id": GID, "name": HOSTILE})
+ await ops.attach_group(state, HOSTILE, str(tmp_path / "share"))
+
+ hosted = _hosted(tmp_path)
+ assert hosted["name"] == HOSTILE
+ assert hosted["node"]["ui_port"] == 18000
+
+
+async def test_a_folder_name_cannot_either(tmp_path):
+ state = _state(tmp_path, {"id": GID, "name": "Films"})
+ await ops.attach_group(state, "Films", str(tmp_path / "share"))
+ state["config"] = load_config(tmp_path / "node.toml")
+ # Root names are refused with such characters already (roots.py); the
+ # path is not, and is the operator's own folder or a member's request.
+ weird = tmp_path / 'a "quoted"\\ folder\n[node]'
+ await ops.add_root(state, GID, str(weird), name="extra")
+
+ hosted = _hosted(tmp_path)
+ assert Path(hosted["roots"][-1]["path"]) == weird
+ assert hosted["node"]["ui_port"] == 18000
+
+
+@pytest.mark.parametrize("value", ["plain", 'q"uote', "back\\slash", "line\nbreak",
+ "tab\there", "del\x7f", "café 日本"])
+def test_every_string_reads_back_as_written(value):
+ assert tomllib.loads(f"v = {toml_string(value)}")["v"] == value
diff --git a/packages/meshbay-node/tests/test_ops.py b/packages/meshbay-node/tests/test_ops.py
index 9eb8339..2c5a15a 100644
--- a/packages/meshbay-node/tests/test_ops.py
+++ b/packages/meshbay-node/tests/test_ops.py
@@ -405,7 +405,7 @@ def test_every_path_written_into_node_toml_goes_through_as_posix():
import re
source = ops_source()
# Every f-string interpolation that lands on the right of a TOML `path =`.
- writes = re.findall(r'path\s*=\s*\\?"\{([^}]+)\}', source)
+ writes = re.findall(r'path\s*=\s*\\?"?\{(?:toml_string\()?([^}]+)\}', source)
assert writes, "no TOML path writer found — did the config writer move?"
for expr in writes:
assert "as_posix()" in expr, (