diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 11:49:56 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 11:49:56 +0200 |
| commit | d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 (patch) | |
| tree | 95ff1252c80a71e93c5098822d31b835572d8b52 /packages/meshbay-hub/src/meshbay_hub/api | |
| parent | 69554fac7eba6eef7eb8a1c0111c5b92e7f21256 (diff) | |
| download | meshbay-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')
5 files changed, 298 insertions, 30 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index df2f336..10049b2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -18,8 +18,11 @@ from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( FederatedGroup, Group, + GroupHost, + GroupInvitation, GroupMember, IPLog, + Node, User, ) @@ -80,6 +83,71 @@ async def my_groups( } +@router.get("/invitations") +async def my_invitations( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Groups somebody added this account to, waiting for it to say yes. + + Nothing here is dialled or searched: until the invitation is accepted the + group is not in `/mine`, is not named in any MNP token, and signaling to + its nodes is refused like any non-member's. + """ + rows = (await db.execute( + select(GroupInvitation, Group, User.username) + .join(Group, Group.id == GroupInvitation.group_id) + .outerjoin(User, User.id == GroupInvitation.invited_by) + .where(GroupInvitation.user_id == current_user.id, Group.status == "active") + .order_by(GroupInvitation.created_at.desc()))).all() + owners = dict((await db.execute( + select(User.id, User.username).where( + User.id.in_({g.admin_id for _, g, _ in rows})))).all()) if rows else {} + return {"invitations": [ + {"group_id": g.id, "name": g.name, + "owner_username": owners.get(g.admin_id, ""), + "invited_by": inviter or "", + "created_at": inv.created_at.isoformat() if inv.created_at else None} + for inv, g, inviter in rows + ]} + + +@router.post("/{group_id}/invitation/accept") +async def accept_invitation( + group_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + inv = await db.get(GroupInvitation, (group_id, current_user.id)) + group = await db.get(Group, group_id) + if inv is None or group is None or group.status != "active": + raise HTTPException(status_code=404, detail="No such invitation") + await db.delete(inv) + if not await db.get(GroupMember, (group_id, current_user.id)): + db.add(GroupMember(group_id=group_id, user_id=current_user.id)) + db.add(IPLog(user_id=current_user.id, event="group_join", + ip_address=client_ip(request), detail=group.name)) + await db.commit() + owner = await db.scalar(select(User.username).where(User.id == group.admin_id)) + return {"status": "joined", "group_id": group_id, "name": group.name, + "owner_username": owner} + + +@router.post("/{group_id}/invitation/decline") +async def decline_invitation( + group_id: str, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + inv = await db.get(GroupInvitation, (group_id, current_user.id)) + if inv is None: + raise HTTPException(status_code=404, detail="No such invitation") + await db.delete(inv) + await db.commit() + return {"status": "declined", "group_id": group_id} + + @router.post("/{group_id}/activity") async def touch_group_activity( group_id: str, @@ -226,11 +294,21 @@ async def group_members( .where(GroupMember.group_id == group_id, User.status != "deleted") ) members = [{"user_id": uid, "username": uname} for uid, uname in result.all()] - return { + out = { "group_id": group_id, "admin_id": group.admin_id, "members": members, } + if group.admin_id == current_user.id: + # Who has been asked and not answered, for the owner only: another + # member learns nothing about people who have not joined. + invited = await db.execute( + select(User.id, User.username) + .join(GroupInvitation, User.id == GroupInvitation.user_id) + .where(GroupInvitation.group_id == group_id, User.status != "deleted")) + out["invited"] = [{"user_id": uid, "username": uname} + for uid, uname in invited.all()] + return out @router.post("/{group_id}/join") @@ -258,6 +336,9 @@ async def join_group( raise HTTPException(status_code=409, detail="Already a member") db.add(GroupMember(group_id=group_id, user_id=current_user.id)) + inv = await db.get(GroupInvitation, (group_id, current_user.id)) + if inv is not None: + await db.delete(inv) db.add(IPLog(user_id=current_user.id, event="group_join", ip_address=client_ip(request), detail=group.name)) await db.commit() @@ -437,10 +518,15 @@ async def remove_group_member( "group over or delete it.") membership = await db.get(GroupMember, (group_id, target.id)) - if not membership: + invitation = await db.get(GroupInvitation, (group_id, target.id)) + if not membership and not invitation: raise HTTPException(status_code=404, detail="Not a member of this group") - await db.delete(membership) + # An unanswered invitation is taken back the same way, by the same button. + if invitation is not None: + await db.delete(invitation) + if membership is not None: + await db.delete(membership) db.add(IPLog(user_id=current_user.id, event="group_leave", ip_address=client_ip(request), detail=f"{username} removed from {group.name}")) @@ -554,23 +640,23 @@ async def add_group_member( if not target: raise HTTPException(status_code=404, detail="User not found") - new_member = False - mem = await db.get(GroupMember, (group_id, target.id)) - if not mem: - db.add(GroupMember(group_id=group_id, user_id=target.id)) - new_member = True - - if new_member: + # An invitation, not a membership: the invitee has not agreed to anything, + # and a membership is what makes their client dial this group's nodes and + # name it in the tokens it hands them. They accept it themselves + # (`POST /{id}/invitation/accept`); until then the group is not theirs. + if await db.get(GroupMember, (group_id, target.id)): + return {"status": "member", "group_id": group_id, "username": username} + if await db.get(GroupInvitation, (group_id, target.id)) is None: + db.add(GroupInvitation(group_id=group_id, user_id=target.id, + invited_by=current_user.id)) from meshbay_hub.api.notifications import create_notification await create_notification( db, target.id, "group_invite", - f"You were added to {group.name}", - link=f"#/group/{group_id}", - group_id=group_id, + f"{current_user.username} invited you to a group", + link="#/", ) - await db.commit() - return {"status": "stored", "group_id": group_id, "username": username} + return {"status": "invited", "group_id": group_id, "username": username} class MuteRequest(BaseModel): @@ -695,3 +781,93 @@ async def invite_notify( return {"status": "sent"} + + +# ── Hosts: which nodes may serve this group ───────────────────────────────── +# +# A node owned by the group's owner hosts it without asking. Any other node — +# a member's, or the owner's own on another account — is registered for the +# group only once the owner approves it here. A node that claims a group it may +# not host appears in this list as `pending`, and the owner was notified. + +async def _owned(db: AsyncSession, group_id: str, user: User) -> Group: + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group.admin_id != user.id: + raise HTTPException(status_code=403, + detail="Only the group owner can choose its hosts") + return group + + +@router.get("/{group_id}/hosts") +async def list_hosts( + group_id: str, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + from meshbay_hub.api.revocation import is_node_connected + await _owned(db, group_id, current_user) + rows = (await db.execute( + select(GroupHost, Node, User.username) + .join(Node, Node.id == GroupHost.node_id) + .outerjoin(User, User.id == Node.user_id) + .where(GroupHost.group_id == group_id) + .order_by(GroupHost.requested_at))).all() + return {"hosts": [ + {"node_id": n.id, "pk_node": n.pk_node, "username": uname or "", + "status": h.status, "online": is_node_connected(n.id), + "requested_at": h.requested_at.isoformat() if h.requested_at else None} + for h, n, uname in rows + ]} + + +@router.post("/{group_id}/hosts/{node_id}") +async def approve_host( + group_id: str, + node_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Approve a node that asked to host this group. Only a request the node + itself made can be approved: the owner picks from what asked, and never + names a node that did not.""" + from meshbay_hub.api.revocation import refresh_node_groups + group = await _owned(db, group_id, current_user) + host = await db.get(GroupHost, (group_id, node_id)) + if host is None: + raise HTTPException(status_code=404, detail="That node has not asked to host this group") + host.status = "approved" + host.decided_at = datetime.now(UTC) + db.add(IPLog(user_id=current_user.id, event="group_host_approve", + ip_address=client_ip(request), detail=f"{group.name}: {node_id[:8]}")) + await db.commit() + await refresh_node_groups(node_id) + return {"status": "approved", "group_id": group_id, "node_id": node_id} + + +@router.delete("/{group_id}/hosts/{node_id}") +async def remove_host( + group_id: str, + node_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Withdraw an approval, or turn a request down. Takes effect at once for a + connected node. The row stays, marked `refused`, so the node asking again + on every reconnection does not notify the owner every time; approving it + later is still one call.""" + from meshbay_hub.api.revocation import refresh_node_groups + group = await _owned(db, group_id, current_user) + host = await db.get(GroupHost, (group_id, node_id)) + if host is None: + raise HTTPException(status_code=404, detail="No such host") + host.status = "refused" + host.decided_at = datetime.now(UTC) + db.add(IPLog(user_id=current_user.id, event="group_host_remove", + ip_address=client_ip(request), detail=f"{group.name}: {node_id[:8]}")) + await db.commit() + await refresh_node_groups(node_id) + return {"status": "refused", "group_id": group_id, "node_id": node_id} diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py index 3c50e19..86fbbb4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py @@ -30,7 +30,7 @@ from meshbay_hub import mail from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope from meshbay_hub.api.middleware import limiter from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User +from meshbay_hub.db.models import Group, GroupInvitation, GroupInviteLink, GroupMember, User router = APIRouter(prefix="/v1/groups", tags=["invite-links"]) redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"]) @@ -322,6 +322,11 @@ async def redeem_invite_link( raise HTTPException(status_code=404, detail="invite_not_valid") if not await db.get(GroupMember, (group.id, current_user.id)): db.add(GroupMember(group_id=group.id, user_id=current_user.id)) + # Redeeming a link is the invitee's own act, so it answers an + # invitation to the same group as well. + pending = await db.get(GroupInvitation, (group.id, current_user.id)) + if pending is not None: + await db.delete(pending) from meshbay_hub.api.notifications import create_notification await create_notification( db, row.created_by, "invite_link_redeemed", diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 403f450..decd20e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -26,6 +26,10 @@ class MnpTokenRequest(BaseModel): # to it (E10), so it cannot be replayed to another node. The client knows it # from `/v1/groups/{id}/nodes` before it connects. node_pk: str = "" + # The one group this connection is for. The token names that group and no + # other: it is handed to the node's operator, who has no business learning + # every other group the member belongs to. + group_id: str = "" @router.post("/mnp-token") @@ -50,11 +54,16 @@ async def mnp_token( only *restricts* the token to whatever node holds that key, which is the one the client is connecting to; a wrong key yields a token no node will accept. """ - rows = await db.execute( - select(GroupMember.group_id).where(GroupMember.user_id == current_user.id)) - group_ids = [gid for (gid,) in rows.all()] + group_id = (body.group_id if body else "").strip() + if not group_id: + raise HTTPException(status_code=422, + detail="Name the group this connection is for (group_id)") + member = await db.get(GroupMember, (group_id, current_user.id)) return { - "mnp_token": issue_mnp_token(current_user.id, groups=group_ids, + # Empty when the account is not a member: the node then refuses with + # `not_a_member`, which is the answer that case has always had. + "mnp_token": issue_mnp_token(current_user.id, + groups=[group_id] if member else [], node_pk=(body.node_pk if body else "")), "expires_in": 900, } 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)) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 3e996a7..660bd76 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -35,6 +35,8 @@ from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( EmailVerification, Group, + GroupHost, + GroupInvitation, GroupInviteLink, GroupMember, IPLog, @@ -1415,6 +1417,11 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus await db.execute(delete(GroupMember).where(GroupMember.user_id == user.id)) await db.execute(delete(Notification).where(Notification.user_id == user.id)) await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id)) + await db.execute(delete(GroupInvitation).where(GroupInvitation.user_id == user.id)) + await db.execute(update(GroupInvitation).where(GroupInvitation.invited_by == user.id) + .values(invited_by=None)) + await db.execute(delete(GroupHost).where( + GroupHost.node_id.in_(select(Node.id).where(Node.user_id == user.id)))) await db.execute(delete(Node).where(Node.user_id == user.id)) await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id)) await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id)) |