From 2e9490ca27047ae03e495d397abbe1aec1b2273a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 20 Aug 2026 22:21:19 +0200 Subject: feat: unified group management, public groups, and activity-based sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-node/src/meshbay_node/ops.py | 94 +++++++++++++++++++-------- 1 file changed, 66 insertions(+), 28 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/ops.py') 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"} -- cgit v1.2.3