From b5b4f188a39fc96c4d32e67151e067b1add6dcfc Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 28 Aug 2026 02:51:00 +0200 Subject: feat(hub): let a hub admin disable public groups instance-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new General tab in Administration carries one switch, allow_public_groups, stored in a hub_settings key/value table (runtime-editable, unlike hub.toml). Default is on; an absent row means on, so an upgrade changes nothing. Enforcement is server-side on every hub-mediated path, not just the SPA: - create_group refuses visibility=public (403), staff included - list_public_groups the directory returns nothing (local + federated) - join_group open-joining a public group is refused - group_online_nodes a non-member of a public group is handed no node - signaling.webrtc_offer drops the "node hosts an open group" fallback - federation.export_directory advertises nothing to peer hubs The switch is read live, so flipping it back restores every path. Existing members of a group that predates the switch keep their membership row and their access — this is plan A, not a purge. GET /v1/hub/info exposes the flag (unauthenticated) so the create-group form and the sidebar's "Public groups" link render correctly. Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets status=revoked and broadcasts a signed revocation every node enforces (denylist + dropped live sessions). It is confirm-guarded and names the group. And a message fix the revoke work surfaced: group_online_nodes, join_group and webrtc_offer answered "Group is suspended" for any non-active status. They now report the real state, so a member of a revoked group is told "Group is revoked" rather than something reversible-sounding. Tests: test_public_groups_toggle.py (10) covers the switch end to end and the five enforcement paths; test_revocation.py gains the status-message assertion. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi --- packages/meshbay-hub/src/meshbay_hub/api/groups.py | 34 ++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/api/groups.py') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index e78d950..91aa9c4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from sqlalchemy import func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession +from meshbay_hub import hub_settings from meshbay_hub.api.deps import get_current_user, require_user_scope from meshbay_hub.api.netutil import client_ip from meshbay_hub.db.engine import get_db @@ -107,7 +108,17 @@ async def group_online_nodes( if not group: raise HTTPException(status_code=404, detail="Group not found") if group.status != "active": - raise HTTPException(status_code=403, detail="Group is suspended") + # Name the real state: "suspended" is reversible, "revoked" is a signed + # instruction every node enforces. The client shows this string as-is. + raise HTTPException(status_code=403, detail=f"Group is {group.status}") + + # A public group is normally readable by anyone — that is the point of it. + # But when the hub has public groups switched off, an existing one keeps + # working only for the people already in it: no non-member gets handed a + # node to connect to. Members always have a row here, so they are unaffected. + if group.visibility == "public" and not await hub_settings.public_groups_allowed(db): + if not await db.get(GroupMember, (group_id, current_user.id)): + raise HTTPException(status_code=403, detail="Not a member of this group") node_ids = get_online_nodes_for_group(group_id) nodes = [] @@ -127,6 +138,12 @@ async def list_public_groups( include_federated: bool = True, ): """List/search public groups — local and optionally federated. No auth required.""" + # The hub admin can switch public groups off for the whole instance. When + # they have, there is no directory at all — not the local groups that predate + # the switch, and not the federated ones a peer still advertises. The switch + # is read live, so flipping it back brings the directory straight back. + if not await hub_settings.public_groups_allowed(db): + return {"groups": [], "total": 0} # Unhosted groups are absent from the directory: until a node announces it, # a group has no files, no key and nothing to connect to, so listing it only # produces a dead end. Its owner still sees it in /mine while they set it up. @@ -272,9 +289,14 @@ async def join_group( if not group: raise HTTPException(status_code=404, detail="Group not found") if group.status != "active": - raise HTTPException(status_code=403, detail="Group is not active") + raise HTTPException(status_code=403, detail=f"Group is {group.status}") if group.join_policy != "open": raise HTTPException(status_code=403, detail="Group does not allow open joining") + if group.visibility == "public" and not await hub_settings.public_groups_allowed(db): + # The group predates the switch; open joining is off with it. Existing + # members keep their row and their access. + raise HTTPException( + status_code=403, detail="This hub does not allow joining public groups.") existing = await db.get(GroupMember, (group_id, current_user.id)) if existing: @@ -365,6 +387,14 @@ async def create_group( detail="A private group is invite-only. Make it public if you want " "anyone to be able to join.") if body.visibility == "public": + if not await hub_settings.public_groups_allowed(db): + # Instance policy, set by a hub admin in the panel. Absolute — staff + # included — because the way back is to re-enable it, not to slip + # past it. A client that still shows the "open" choice lands here. + raise HTTPException( + status_code=403, + detail="This hub does not allow public groups. Create the group " + "as private — you can still invite people to it.") await _check_public_group_quota(db, current_user) desc = (body.description or "")[:512] if body.description else None -- cgit v1.2.3