diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-20 22:21:19 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-20 22:21:19 +0200 |
| commit | 2e9490ca27047ae03e495d397abbe1aec1b2273a (patch) | |
| tree | 26f850a88565846a139868a4b85c715734751a41 /packages/meshbay-node/src/meshbay_node/ops.py | |
| parent | c8af746c846b5dbc792f7e4f0d806647d513cc5c (diff) | |
| download | meshbay-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.py | 94 |
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"} |