summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-20 22:21:19 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-20 22:21:19 +0200
commit2e9490ca27047ae03e495d397abbe1aec1b2273a (patch)
tree26f850a88565846a139868a4b85c715734751a41 /packages/meshbay-node/src/meshbay_node/ops.py
parentc8af746c846b5dbc792f7e4f0d806647d513cc5c (diff)
downloadmeshbay-2e9490ca27047ae03e495d397abbe1aec1b2273a.tar.gz
feat: unified group management, public groups, and activity-based sidebar
Create Group wizard (Electron-only) consolidates 6 steps across 4 interfaces into a single multi-step page: group creation on hub, node attachment, root selection via folder picker, GEK initialization, and auto-pairing — all in one flow. Browser SPA keeps its current behavior unchanged. Public group support (Option A — GEK for all groups): - All groups have GEK regardless of visibility; open-join groups auto-admit via TOFU when join_policy is "open" - Key rotation blocked for public groups (API guard + UI hidden) - Hub signaling allows WebRTC offers for nodes hosting open-join groups even when the caller isn't a member yet - attach_group writes join_policy to node.toml - Daemon loads GEK for all groups, not just private ones - Known-device path in join_request now auto-admits to open-join groups Node loopback API bridge (Electron IPC): - node:detect, node:call, node:pairing-code IPC handlers in main process - Renderer never sees tokens, paths, or keys (session token = physical access) - platform.js node namespace for UI consumption - Loopback endpoints: roots CRUD, member-upload toggle, reload Bug fixes: - Root change detection: removed premature ctx["roots"] updates from add_root and remove_root that prevented indexer retarget on reload - Duplicate offline message: global fallback now gated on !group - Signaling membership check: fallback to open-join groups for non-members Sidebar groups sorted by last_activity_at (most recent first): - New Group.last_activity_at column with Alembic migration - POST /v1/groups/{id}/activity endpoint, called on connect and chat send - Client-side sort + throttled hub updates (1/min) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py94
1 files changed, 66 insertions, 28 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 2a76290..daddf17 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -157,38 +157,44 @@ async def pair_operator(state: dict) -> dict:
return {"code": code, "expires_at": expires, "user_id": user_id}
-async def create_invite(state: dict, group_id: str, username: str) -> dict:
+async def create_invite(state: dict, group_id: str, username: str, *,
+ user_id: str = "",
+ created_by: str = "local-cli") -> dict:
"""
Issue an invitation code.
The hub is asked for the account id and nothing else — never for a key. A hub
that answered with the wrong account would produce an invite whose code it
never learns, since the code goes to a human out of band.
+
+ When ``user_id`` is supplied directly (MNP path), the hub lookup is skipped.
"""
roster = _roster(state)
_group_ctx(state, group_id)
- hub = _hub(state)
- try:
- account = await hub.get_user_pubkeys(username)
- except Exception as e:
- raise OpError(f"Unknown user {username!r}: {e}", status=404) from e
+ if not user_id:
+ hub = _hub(state)
+ try:
+ account = await hub.get_user_pubkeys(username)
+ except Exception as e:
+ raise OpError(f"Unknown user {username!r}: {e}", status=404) from e
+ user_id = account["user_id"]
config = state.get("config")
ttl = (config.node.invite_ttl_hours if config else 168) * 3600
code = await roster.create_invite(
group_id=group_id,
- user_id=account["user_id"],
+ user_id=user_id,
role=ROLE_MEMBER,
- created_by="local-cli",
+ created_by=created_by,
ttl=ttl,
username=username,
)
invites = await roster.list_invites()
expires = next((i["expires_at"] for i in invites
- if i["user_id"] == account["user_id"]
+ if i["user_id"] == user_id
and i["group_id"] == group_id), "")
return {"code": code, "expires_at": expires,
- "username": username, "user_id": account["user_id"]}
+ "username": username, "user_id": user_id}
async def revoke_member(state: dict, user_id: str, group_id: str) -> dict:
@@ -236,6 +242,10 @@ async def set_gek(state: dict, group_id: str, *, rotate: bool = False) -> dict:
ctx = _group_ctx(state, group_id)
hub = _hub(state)
+ if rotate and ctx.get("visibility") == "public":
+ raise OpError(
+ "Key rotation is not available for public groups", status=400)
+
bundle_store = state.get("bundle_store")
if not bundle_store:
raise OpError("Bundle store not available", status=503)
@@ -361,20 +371,25 @@ async def attach_group(state: dict, name: str, shared_dir: str,
# 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.
+ join_policy = group.get("join_policy", "invite")
block = (f'\n[[groups]]\n'
f'id = "{group["id"]}"\n'
f'name = "{group["name"]}"\n'
- f'visibility = "{group.get("visibility", "private")}"\n')
+ f'visibility = "{group.get("visibility", "private")}"\n'
+ f'join_policy = "{join_policy}"\n')
+ separate_upload = False
if upload_dir:
- upload_path = Path(upload_dir).expanduser()
- try:
- upload_path.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- raise OpError(f"Cannot create {upload_path}: {e}") from e
- block += f'upload_dir = "{upload_path}"\n'
+ upload_path = Path(upload_dir).expanduser().resolve()
+ if upload_path != path.resolve():
+ separate_upload = True
+ try:
+ upload_path.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ raise OpError(f"Cannot create {upload_path}: {e}") from e
+ block += f'upload_dir = "{upload_path}"\n'
block += (f'\n [[groups.roots]]\n'
f' path = "{path}"\n')
- if not upload_dir:
+ if not separate_upload:
block += f' upload = true\n'
try:
with conf_path.open("a") as f:
@@ -385,7 +400,7 @@ async def attach_group(state: dict, name: str, shared_dir: str,
result = {"group_id": group["id"], "name": group["name"],
"shared_dir": str(path), "config": str(conf_path),
"note": "restart the node to pick it up"}
- if upload_dir:
+ if separate_upload:
result["upload_dir"] = str(upload_path)
return result
@@ -552,9 +567,6 @@ async def add_root(state: dict, group_id: str, path: str, *,
cfg.roots.append(RootSpec(
path=str(added.path), name=added.name, kind=added.kind,
upload=added.upload, direct=added.direct))
- groups_ctx = state.get("groups_ctx", {})
- if group_id in groups_ctx:
- groups_ctx[group_id]["roots"] = built
log.info("Root added: %s → group %s", added.name, group_id[:8])
return {"status": "added", "name": added.name, "path": str(added.path),
@@ -602,15 +614,10 @@ async def remove_root(state: dict, group_id: str, root_name: str) -> dict:
built = RootSet.build(remaining)
except RootError:
built = None
- if built is not None:
- groups_ctx = state.get("groups_ctx", {})
- if group_id in groups_ctx:
- groups_ctx[group_id]["roots"] = built
log.info("Root removed: %s from group %s", root_name, group_id[:8])
return {"status": "removed", "name": root_name, "group_id": group_id,
- "roots": built.describe() if built else [],
- "note": "restart recommended to update the file index"}
+ "roots": built.describe() if built else []}
# ── Files ────────────────────────────────────────────────────────────────────
@@ -682,3 +689,34 @@ async def clear_denylist(state: dict, *, subject: str = "") -> dict:
log.warning("Denylist cleared (%s): %d entr(y/ies) removed",
subject or "all", removed)
return {"status": "cleared", "removed": removed, "subject": subject or "all"}
+
+
+# ── Upload policy ───────────────────────────────────────────────────────────
+
+async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict:
+ """
+ Turn uploading by ordinary members on or off.
+
+ The setting lives on the node (roster.db), not on the hub and not in
+ node.toml — changing it must not rewrite the operator's config file,
+ and must not need a restart.
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ await roster.set_member_upload(group_id, allowed,
+ set_by=state.get("node_user_id", ""))
+ ctx["member_upload"] = allowed
+ log.info("Upload policy: %s for group %s", "on" if allowed else "off",
+ group_id[:8])
+ return {"allowed": allowed, "group_id": group_id}
+
+
+# ── Reload ──────────────────────────────────────────────────────────────────
+
+async def reload_config(state: dict) -> dict:
+ """Hot-reload node.toml without dropping connections."""
+ reload_fn = state.get("reload_fn")
+ if not reload_fn:
+ raise OpError("Reload not available", status=503)
+ await reload_fn()
+ return {"status": "reloaded"}