From d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 30 Sep 2026 11:49:56 +0200 Subject: fix: only the owner decides who hosts a group, and nobody is made a member unasked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hub: a node may host a group only if its account owns it or the owner approved that node (new `group_hosts`). Membership was the ceiling, and every member holds the group key, so any member's node could register as a host and be the one clients kept. A node claiming a group it may not host is recorded as a request; the owner is notified once and approves or refuses it (GET/POST/DELETE /v1/groups/{id}/hosts[/{node_id}]), which takes effect on a connected node at once. - hub: an owner adding a username creates an invitation (new `group_invitations`), accepted or declined by the invitee (/v1/groups/invitations, /{id}/invitation/accept|decline). Until then the group is not listed, not dialled, not searched and not in any token. Invitation links, open joins and group creation still make members directly: they are the account's own act. - hub: the MNP token names only the group it is minted for (group_id is now required), so a node operator no longer learns a member's other groups. - SPA: invitations on the home page; invited people and host requests in the group's settings; the transport sends group_id. Ten catalogues. - Browser probes for both screens, run in Chrome and Firefox. - Design §5.2, §7.2, §7.3, AV32, AV33; protocol §6.3; user guide. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-hub/src/meshbay_hub/api/revocation.py | 91 +++++++++++++++++++--- 1 file changed, 81 insertions(+), 10 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/api/revocation.py') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index fe9edf3..6a6baa7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -52,6 +52,10 @@ router = APIRouter(tags=["revocation"]) _connected_nodes: dict[str, WebSocket] = {} # node_id → websocket _node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...] +# What each connected node asked for, and whose it is, so that an owner +# approving or withdrawing a host takes effect without the node reconnecting. +_node_claims: dict[str, list[str]] = {} # node_id → claimed group ids +_node_users: dict[str, str] = {} # node_id → owning account _punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event # How long an unauthenticated socket may stay open before saying who it is. The @@ -98,6 +102,8 @@ def forget_node(node_id: str) -> None: """ _connected_nodes.pop(node_id, None) _node_groups.pop(node_id, None) + _node_claims.pop(node_id, None) + _node_users.pop(node_id, None) def _notify_budget(node_id: str) -> bool: @@ -283,6 +289,72 @@ async def _authorized_groups(user_id: str) -> set[str]: return {gid for (gid,) in result.all()} +async def _hostable_groups(db: AsyncSession, node_id: str, user_id: str) -> set[str]: + """The groups this node may host: its account's own, and those whose owner + approved it (`GroupHost`). Membership alone is not enough — every member + holds the group key, so a member's node would pass the handshake as though + it were the real host.""" + from meshbay_hub.db.models import GroupHost + owned = set((await db.execute( + select(Group.id).where(Group.admin_id == user_id))).scalars().all()) + approved = set((await db.execute( + select(GroupHost.group_id).where(GroupHost.node_id == node_id, + GroupHost.status == "approved"))).scalars().all()) + return owned | approved + + +async def _record_host_requests(db: AsyncSession, node_id: str, user_id: str, + group_ids: set[str]) -> None: + """Remember that this node would like to host these groups, and tell each + owner once — the first time — rather than on every reconnection.""" + from meshbay_hub.api.notifications import create_notification + from meshbay_hub.db.models import GroupHost + if not group_ids: + return + known = set((await db.execute( + select(GroupHost.group_id).where(GroupHost.node_id == node_id, + GroupHost.group_id.in_(group_ids)))).scalars().all()) + fresh = group_ids - known + if not fresh: + return + who = await db.scalar(select(User.username).where(User.id == user_id)) or "" + for gid in sorted(fresh): + db.add(GroupHost(group_id=gid, node_id=node_id, status="pending")) + group = await db.get(Group, gid) + if group is not None: + await create_notification( + db, group.admin_id, "host_request", + f"A node of {who} asks to host {group.name}", + link=f"#/group/{gid}", group_id=gid) + await db.commit() + + +async def resolve_node_groups(node_id: str, user_id: str, claimed_groups) -> list[str]: + """What a node is registered for: what it claims, within what its account + belongs to and what it may host. The rest of its claim becomes a request + the owner can approve.""" + from meshbay_hub.db.engine import get_session_factory + async with get_session_factory()() as db: + result = await db.execute( + select(GroupMember.group_id).where(GroupMember.user_id == user_id)) + authorized = {gid for (gid,) in result.all()} + allowed = _claimable(claimed_groups, authorized) + hostable = await _hostable_groups(db, node_id, user_id) + await _record_host_requests(db, node_id, user_id, + set(allowed) - hostable) + return [gid for gid in allowed if gid in hostable] + + +async def refresh_node_groups(node_id: str) -> None: + """Re-evaluate a connected node's registration after a host decision.""" + if node_id not in _connected_nodes: + return + new_gids = await resolve_node_groups( + node_id, _node_users.get(node_id, ""), _node_claims.get(node_id)) + _node_groups[node_id] = new_gids + await _mark_hosted(new_gids) + + def _claimable(claimed_groups, authorized: set[str]) -> list[str]: """What a node actually gets registered for. Two rules. @@ -337,14 +409,10 @@ async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tup if user is None or user.status != "active": return None, "Account not active" - # Groups come from the database. The node may narrow the set to what it - # actually hosts, but it cannot widen it to groups it is not a member of — - # otherwise it could advertise itself as a source for any group on the hub. - result = await db.execute( - select(GroupMember.group_id).where(GroupMember.user_id == user_id)) - authorized = {gid for (gid,) in result.all()} - - return claimed_id, _claimable(claimed_groups, authorized) + # Groups come from the database. The node may narrow the set to what it + # actually hosts, but it cannot widen it past what its account belongs to + # (C2) — nor, within that, past what its account owns or the owner approved. + return claimed_id, await resolve_node_groups(claimed_id, user_id, claimed_groups) @router.websocket("/v1/nodes/ws") @@ -404,6 +472,8 @@ async def node_websocket(ws: WebSocket): node_id = resolved_id _connected_nodes[node_id] = ws _node_groups[node_id] = group_ids + _node_claims[node_id] = list(msg.get("group_ids") or []) + _node_users[node_id] = user_id await _mark_hosted(group_ids) log.info("Node WS connected: %s (user=%s, groups=%d)", node_id[:8], user_id[:8], len(group_ids)) @@ -427,8 +497,9 @@ async def node_websocket(ws: WebSocket): # assign the message's list verbatim, so the ceiling that makes # C2 hold at authentication could be stepped over one message # later: a node had only to reload to claim any group on the hub. - new_gids = _claimable(msg.get("group_ids"), - await _authorized_groups(user_id)) + _node_claims[node_id] = list(msg.get("group_ids") or []) + new_gids = await resolve_node_groups( + node_id, user_id, msg.get("group_ids")) _node_groups[node_id] = new_gids await _mark_hosted(new_gids) log.info("Node %s updated groups: %d", node_id[:8], len(new_gids)) -- cgit v1.2.3