aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
commitd3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 (patch)
tree95ff1252c80a71e93c5098822d31b835572d8b52 /packages/meshbay-hub/src/meshbay_hub/api/revocation.py
parent69554fac7eba6eef7eb8a1c0111c5b92e7f21256 (diff)
downloadmeshbay-d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4.tar.gz
fix: only the owner decides who hosts a group, and nobody is made a member unasked
- 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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/revocation.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py91
1 files changed, 81 insertions, 10 deletions
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))