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 --- .../meshbay-hub/src/meshbay_hub/api/signaling.py | 33 ++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/api/signaling.py') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py index 84c1167..a8feae8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -22,6 +22,7 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from meshbay_hub import hub_settings from meshbay_hub.api.deps import get_current_user from meshbay_hub.api.middleware import limiter from meshbay_hub.db.engine import get_db @@ -93,6 +94,11 @@ async def webrtc_offer( # The caller must share at least one active group with the target node, # OR the node must host at least one open-join group (public groups admit # anyone — the node's MNP handshake handles authorization). + # + # That second path is exactly what "public groups" means, so it is gated by + # the instance switch: with public groups off, a non-member is not brokered a + # connection to a node just because it happens to host an open group. Members + # of that group are unaffected — they match `shared` below. node_group_ids = set(_node_groups.get(node_id, [])) if node_group_ids: result = await db.execute( @@ -102,19 +108,24 @@ async def webrtc_offer( )) shared = [gid for (gid,) in result.all()] if not shared: - has_open = await db.execute( - select(Group.id).where( - Group.id.in_(node_group_ids), - Group.join_policy == "open", - Group.status == "active", - )) - if not has_open.first(): + has_open = None + if await hub_settings.public_groups_allowed(db): + has_open = (await db.execute( + select(Group.id).where( + Group.id.in_(node_group_ids), + Group.join_policy == "open", + Group.status == "active", + ))).first() + if not has_open: raise HTTPException(status_code=403, detail="Not a member of any group on this node") else: - active = await db.execute( - select(Group.id).where(Group.id.in_(shared), Group.status == "active")) - if not active.first(): - raise HTTPException(status_code=403, detail="Group is not active") + statuses = set((await db.execute( + select(Group.status).where(Group.id.in_(shared)))).scalars().all()) + if "active" not in statuses: + # Report the strongest state present — "revoked" is the signed, + # node-enforced one; "suspended" is the reversible hub flag. + state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended") + raise HTTPException(status_code=403, detail=f"Group is {state}") if _pending_per_user.get(current_user.id, 0) >= MAX_PENDING_PER_USER: raise HTTPException(status_code=429, detail="Too many pending connections") -- cgit v1.2.3