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 | |
| 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')
38 files changed, 1518 insertions, 81 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)) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a1b2c3d4e5f7_invitations_and_group_hosts.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a1b2c3d4e5f7_invitations_and_group_hosts.py new file mode 100644 index 0000000..b47fca0 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a1b2c3d4e5f7_invitations_and_group_hosts.py @@ -0,0 +1,49 @@ +"""an owner's addition is an invitation; hosts are designated by the owner + +Adding somebody to a group no longer makes them a member until they accept, and +a node may host a group only if its account owns it or the owner approved it. + +Revision ID: a1b2c3d4e5f7 +Revises: f7a8b9c0d1e2 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a1b2c3d4e5f7" +down_revision: str | Sequence[str] | None = "f7a8b9c0d1e2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "group_invitations", + sa.Column("group_id", sa.String(36), sa.ForeignKey("groups.id"), nullable=False), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("invited_by", sa.String(36), sa.ForeignKey("users.id"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.PrimaryKeyConstraint("group_id", "user_id"), + ) + op.create_index("ix_group_invitations_user", "group_invitations", ["user_id"]) + op.create_table( + "group_hosts", + sa.Column("group_id", sa.String(36), sa.ForeignKey("groups.id"), nullable=False), + sa.Column("node_id", sa.String(36), sa.ForeignKey("nodes.id"), nullable=False), + sa.Column("status", sa.String(16), nullable=False, server_default="pending"), + sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("group_id", "node_id"), + ) + op.create_index("ix_group_hosts_node", "group_hosts", ["node_id"]) + + +def downgrade() -> None: + op.drop_index("ix_group_hosts_node", table_name="group_hosts") + op.drop_table("group_hosts") + op.drop_index("ix_group_invitations_user", table_name="group_invitations") + op.drop_table("group_invitations") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 5b140f1..a0437d6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -145,6 +145,53 @@ class GroupMember(Base): user: Mapped["User"] = relationship(back_populates="group_memberships") +class GroupInvitation(Base): + """ + Somebody asked to add this account to a group, and it has not said yes. + + Separate from `GroupMember` on purpose. A membership row is what the hub + acts on — it lets an account's client dial the group's nodes, names the + group in the account's MNP tokens and lists the group in its sidebar and in + Search — and an owner could create one for any username, unasked. That made + any account able to have any other account's client connect to a node of + its choosing. So an owner's addition is an invitation until the invitee + accepts it; redeeming an invitation link, joining an open group and creating + a group are the account's own acts and still write the membership directly. + """ + __tablename__ = "group_invitations" + + group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), primary_key=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True) + invited_by: Mapped[str | None] = mapped_column(ForeignKey("users.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + __table_args__ = (Index("ix_group_invitations_user", "user_id"),) + + +class GroupHost(Base): + """ + A node the group's owner has approved as a host, refused, or not yet + answered (`approved` / `refused` / `pending`). + + A node registers for the groups it claims, and clients connect to whichever + registered node answers first. Every member holds the group key, so a + member's node passes the handshake like the real host would: the ceiling on + what a node may claim cannot be "groups its account belongs to". It is + "groups its account owns", plus the nodes listed here as `approved`. A node + that claims a group it may not host is recorded `pending`, and the owner is + told, so a legitimate second host is one click away rather than refused. + """ + __tablename__ = "group_hosts" + + group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), primary_key=True) + node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), primary_key=True) + status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False) + requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + __table_args__ = (Index("ix_group_hosts_node", "node_id"),) + + class GroupInviteLink(Base): """ The hub's half of an invitation link (docs/MESHBAY_DESIGN.md §7.3). diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index b382849..6980da9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -443,7 +443,8 @@ def send_invite_notification( "\n" f"Your one-time code is: {code}\n" "\n" - "Open the group and enter this code when prompted.\n" + "Accept the invitation on your MeshBay home page, then open the group\n" + "and enter this code when prompted.\n" "The code works once and expires in 7 days.\n" "\n" f"{_hub_url}\n" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 875b1aa..3a85bf7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -528,7 +528,66 @@ function NotificationFeed({ notifications, onMarkRead, onPurge }) { `; } -function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroups = true }) { +/** + * Groups somebody added this account to, waiting for an answer. + * + * Being added is an invitation, not a membership (the hub's `group_invitations`): + * until it is accepted the group is not in the sidebar, not dialled, not in + * Search and not named in any token this browser hands a node. Accepting is + * what makes it a group of ours; declining forgets it. + */ +function PendingInvitations({ token, onAccepted }) { + const [items, setItems] = useState([]); + const [busy, setBusy] = useState(''); + const [error, setError] = useState(''); + const load = useCallback(() => { + if (!token) return; + hubFetch('/v1/groups/invitations', { token }) + .then(data => setItems(data.invitations || [])) + .catch(() => setItems([])); + }, [token]); + useEffect(() => { load(); }, [load]); + + const answer = async (inv, verb) => { + setBusy(inv.group_id); setError(''); + try { + await hubFetch(`/v1/groups/${inv.group_id}/invitation/${verb}`, + { method: 'POST', token }); + setItems(prev => prev.filter(i => i.group_id !== inv.group_id)); + if (verb === 'accept' && onAccepted) onAccepted(inv.group_id); + } catch (err) { + setError(err.message); + } finally { + setBusy(''); + } + }; + + if (!items.length) return null; + return html` + <div class="settings-section"> + <h3 class="settings-heading">${t('home.invitations')}</h3> + <p class="settings-hint">${t('home.invitation_hint')}</p> + ${error && html`<p class="error-msg">${error}</p>`} + <ul class="invite-links"> + ${items.map(inv => html` + <li key=${inv.group_id} style="display:flex;gap:8px;align-items:center; + flex-wrap:wrap;word-break:break-word;margin:4px 0"> + <strong><${GroupName} name=${inv.name} owner=${inv.owner_username} /></strong> + ${inv.invited_by && html`<span style="color:var(--text-dim)"> + ${t('home.invited_by', { name: inv.invited_by })}</span>`} + <button class="admin-btn" type="button" disabled=${busy === inv.group_id} + onClick=${() => answer(inv, 'accept')}>${t('home.accept')}</button> + <button class="admin-btn" type="button" disabled=${busy === inv.group_id} + onClick=${() => answer(inv, 'decline')}>${t('home.decline')}</button> + </li> + `)} + </ul> + </div> + `; +} + +function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroups = true, + token, onGroupsChanged }) { const [setupDismissed, setSetupDismissed] = useState(false); if (groups.length === 0) { @@ -539,6 +598,7 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup return html` <div> <h2>${t('home.welcome')}</h2> + <${PendingInvitations} token=${token} onAccepted=${onGroupsChanged} /> <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} onPurge=${onPurge} /> <${JoinByLink} hubOrigin=${platform.hubOrigin()} /> @@ -555,6 +615,7 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup return html` <div> <h2>${t('home.my_groups')}</h2> + <${PendingInvitations} token=${token} onAccepted=${onGroupsChanged} /> <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} onPurge=${onPurge} /> <div class="group-grid"> @@ -958,6 +1019,15 @@ function App() { fetchNotifications(); }, [user]); + // After an invitation is accepted: the group is ours now, and only the hub + // knows its row the way `/mine` answers it. + const reloadGroups = useCallback(() => { + if (!user) return; + hubFetch('/v1/groups/mine', { token: user.token }) + .then(data => setGroups(data.groups || [])) + .catch(() => {}); + }, [user]); + // The group list lives here, so an edit made three components down has to come // back up rather than be re-fetched: a reload would drop the WebRTC connection // the page is holding. @@ -1216,6 +1286,7 @@ function App() { ? html`<${LazyAdminPage} token=${user.token} role=${user.role} />` : html`<${HomePage} groups=${groups} notifications=${notifications} allowPublicGroups=${allowPublicGroups} + token=${user.token} onGroupsChanged=${reloadGroups} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } else if (route === '/settings') { page = html`<${SettingsPage} user=${user} theme=${theme} @@ -1233,6 +1304,7 @@ function App() { } else { page = html`<${HomePage} groups=${groups} notifications=${notifications} allowPublicGroups=${allowPublicGroups} + token=${user.token} onGroupsChanged=${reloadGroups} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index fba8a0e..048f831 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -410,7 +410,14 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, onAppDirectories, onRefreshIndex, onPaired, onLeft }) { const [members, setMembers] = useState([]); + // Asked and not answered — the owner is the only one told (the hub leaves + // the field out for anyone else). + const [invited, setInvited] = useState([]); const [adminId, setAdminId] = useState(''); + // Nodes other than the owner's that asked to serve this group, and what the + // owner answered. Only the owner reads it. + const [hosts, setHosts] = useState([]); + const [hostBusy, setHostBusy] = useState(''); const [loading, setLoading] = useState(true); const [inviteUser, setInviteUser] = useState(''); const [inviting, setInviting] = useState(false); @@ -802,6 +809,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, hubFetch(`/v1/groups/${groupId}/members`, { token }) .then(data => { setMembers(data.members || []); + setInvited(data.invited || []); setAdminId(data.admin_id || ''); }) .catch(() => {}) @@ -810,6 +818,30 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, useEffect(() => { loadMembers(); }, [loadMembers]); + const ownsGroup = Boolean(group && group.is_admin); + const loadHosts = useCallback(() => { + if (!ownsGroup) return; + hubFetch(`/v1/groups/${groupId}/hosts`, { token }) + .then(data => setHosts(data.hosts || [])) + .catch(() => setHosts([])); + }, [groupId, token, ownsGroup]); + + useEffect(() => { loadHosts(); }, [loadHosts]); + + const decideHost = useCallback(async (host, approve) => { + setHostBusy(host.node_id); + setError(''); + try { + await hubFetch(`/v1/groups/${groupId}/hosts/${host.node_id}`, + { method: approve ? 'POST' : 'DELETE', token }); + loadHosts(); + } catch (err) { + setError(err.message); + } finally { + setHostBusy(''); + } + }, [groupId, token, loadHosts]); + useEffect(() => { hubFetch('/v1/users/me/preferences', { token }) .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] === 'true')) @@ -1382,12 +1414,58 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </td> </tr> `)} + ${invited.map(m => html` + <tr key=${m.user_id}> + <td>${m.username}</td> + <td><span class="badge">${t('members.invited')}</span></td> + <td class="admin-actions"> + ${isAdmin && html` + <button class="admin-btn danger" disabled=${removing === m.user_id} + onClick=${async () => { + if (!await ask(t('members.remove_confirm', { user: m.username }))) return; + removeMember(m); + }}> + ${removing === m.user_id ? '...' : t('members.remove')} + </button> + `} + </td> + </tr> + `)} </tbody> </table> ${isAdmin && members.length > 1 && html` <p class="settings-hint">${t('members.remove_hint')}</p> `} </${CollapsibleSection}> + + ${ownsGroup && html` + <${CollapsibleSection} title=${t('hosts.title')}> + <p class="settings-hint">${t('hosts.hint')}</p> + ${hosts.length === 0 && html`<p class="settings-hint">${t('hosts.none')}</p>`} + ${hosts.length > 0 && html` + <table class="admin-table"> + <tbody> + ${hosts.map(h => html` + <tr key=${h.node_id}> + <td>${t('hosts.from', { name: h.username })} + <br /><code class="node-key">${h.pk_node}</code></td> + <td><span class="badge">${t(`hosts.status_${h.status}`)}</span> + ${h.online && html` <span class="badge">${t('hosts.online')}</span>`}</td> + <td class="admin-actions"> + ${h.status !== 'approved' && html` + <button class="admin-btn" disabled=${hostBusy === h.node_id} + onClick=${() => decideHost(h, true)}>${t('hosts.approve')}</button>`} + ${h.status !== 'refused' && html` + <button class="admin-btn danger" disabled=${hostBusy === h.node_id} + onClick=${() => decideHost(h, false)}>${t('hosts.refuse')}</button>`} + </td> + </tr> + `)} + </tbody> + </table> + `} + </${CollapsibleSection}> + `} </div> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index a83f44b..3c0f1ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1222,4 +1222,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Umbenannt: „{name}“ ist nicht auf jedem System ein gültiger Name", 'transfers.renamed_n': "Namen geändert, damit sie auf jedem System gültig sind: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Einladungen", + 'home.invitation_hint': "Jemand hat Sie zu diesen Gruppen hinzugefügt. Nehmen Sie nur die an, die Sie erwarten: Eine Gruppe, der Sie beitreten, sieht Ihre Adresse, wenn Sie sie öffnen.", + 'home.invited_by': "eingeladen von {name}", + 'home.accept': "Annehmen", + 'home.decline': "Ablehnen", + 'members.invited': "eingeladen", + 'hosts.title': "Hosts", + 'hosts.hint': "Ihre eigenen Nodes stellen diese Gruppe ohne Rückfrage bereit. Ein anderer Node tut es erst, wenn Sie ihn hier genehmigen; ein Node, der anfragt, steht unten.", + 'hosts.none': "Kein anderer Node hat angefragt, diese Gruppe bereitzustellen.", + 'hosts.from': "Ein Node von {name}", + 'hosts.status_pending': "wartet auf Ihre Antwort", + 'hosts.status_approved': "genehmigt", + 'hosts.status_refused': "abgelehnt", + 'hosts.approve': "Genehmigen", + 'hosts.refuse': "Ablehnen", + 'hosts.online': "online", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 46c5094..947c61d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1203,4 +1203,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Renamed: “{name}” is not a valid name on every system", 'transfers.renamed_n': "Names changed to be valid on every system: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Invitations", + 'home.invitation_hint': "Somebody added you to these groups. Accept only the ones you expect: a group you join can see your address when you open it.", + 'home.invited_by': "invited by {name}", + 'home.accept': "Accept", + 'home.decline': "Decline", + 'members.invited': "invited", + 'hosts.title': "Hosts", + 'hosts.hint': "Your own nodes serve this group without asking. Another node serves it only once you approve it here; a node that asks is listed below.", + 'hosts.none': "No other node has asked to host this group.", + 'hosts.from': "A node of {name}", + 'hosts.status_pending': "waiting for your answer", + 'hosts.status_approved': "approved", + 'hosts.status_refused': "refused", + 'hosts.approve': "Approve", + 'hosts.refuse': "Refuse", + 'hosts.online': "online", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 0d0e865..a0220d8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1216,4 +1216,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Renombrado: «{name}» no es un nombre válido en todos los sistemas", 'transfers.renamed_n': "Nombres cambiados para ser válidos en todos los sistemas: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Invitaciones", + 'home.invitation_hint': "Alguien le ha añadido a estos grupos. Acepte solo los que espera: un grupo al que se une ve su dirección cuando lo abre.", + 'home.invited_by': "invitado por {name}", + 'home.accept': "Aceptar", + 'home.decline': "Rechazar", + 'members.invited': "invitado", + 'hosts.title': "Anfitriones", + 'hosts.hint': "Sus propios nodes sirven este grupo sin preguntar. Otro node lo sirve solo cuando usted lo aprueba aquí; un node que lo pide aparece abajo.", + 'hosts.none': "Ningún otro node ha pedido alojar este grupo.", + 'hosts.from': "Un node de {name}", + 'hosts.status_pending': "esperando su respuesta", + 'hosts.status_approved': "aprobado", + 'hosts.status_refused': "rechazado", + 'hosts.approve': "Aprobar", + 'hosts.refuse': "Rechazar", + 'hosts.online': "en línea", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 3c86cd7..c4bc37e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1231,4 +1231,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Renommé : « {name} » n’est pas un nom valide sur tous les systèmes", 'transfers.renamed_n': "Noms modifiés pour être valides sur tous les systèmes : {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Invitations", + 'home.invitation_hint': "Quelqu'un vous a ajouté à ces groupes. N'acceptez que ceux que vous attendez : un groupe que vous rejoignez voit votre adresse quand vous l'ouvrez.", + 'home.invited_by': "invité par {name}", + 'home.accept': "Accepter", + 'home.decline': "Refuser", + 'members.invited': "invité", + 'hosts.title': "Hôtes", + 'hosts.hint': "Vos propres nodes servent ce groupe sans rien demander. Un autre node ne le sert qu'une fois approuvé ici ; un node qui le demande apparaît ci-dessous.", + 'hosts.none': "Aucun autre node n'a demandé à héberger ce groupe.", + 'hosts.from': "Un node de {name}", + 'hosts.status_pending': "en attente de votre réponse", + 'hosts.status_approved': "approuvé", + 'hosts.status_refused': "refusé", + 'hosts.approve': "Approuver", + 'hosts.refuse': "Refuser", + 'hosts.online': "en ligne", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 3a74b4d..59505b8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1230,4 +1230,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Rinominato: «{name}» non è un nome valido su tutti i sistemi", 'transfers.renamed_n': "Nomi modificati per essere validi su tutti i sistemi: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Inviti", + 'home.invitation_hint': "Qualcuno ti ha aggiunto a questi gruppi. Accetta solo quelli che ti aspetti: un gruppo a cui ti unisci vede il tuo indirizzo quando lo apri.", + 'home.invited_by': "invitato da {name}", + 'home.accept': "Accetta", + 'home.decline': "Rifiuta", + 'members.invited': "invitato", + 'hosts.title': "Host", + 'hosts.hint': "I tuoi node servono questo gruppo senza chiedere. Un altro node lo serve solo dopo che lo approvi qui; un node che lo chiede compare qui sotto.", + 'hosts.none': "Nessun altro node ha chiesto di ospitare questo gruppo.", + 'hosts.from': "Un node di {name}", + 'hosts.status_pending': "in attesa della tua risposta", + 'hosts.status_approved': "approvato", + 'hosts.status_refused': "rifiutato", + 'hosts.approve': "Approva", + 'hosts.refuse': "Rifiuta", + 'hosts.online': "online", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index e0c38de..bba9564 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1214,4 +1214,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "名前を変更しました:「{name}」はすべてのシステムで有効な名前ではありません", 'transfers.renamed_n': "すべてのシステムで有効になるよう変更した名前:{n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "招待", + 'home.invitation_hint': "誰かがあなたをこれらのグループに追加しました。心当たりのあるものだけを承諾してください。参加したグループは、開いたときにあなたのアドレスを知ることができます。", + 'home.invited_by': "{name} からの招待", + 'home.accept': "承諾", + 'home.decline': "辞退", + 'members.invited': "招待中", + 'hosts.title': "ホスト", + 'hosts.hint': "あなた自身の node は確認なしでこのグループを提供します。他の node は、ここで承認した後にのみ提供します。申請した node は下に表示されます。", + 'hosts.none': "このグループのホストを申請した node は他にありません。", + 'hosts.from': "{name} の node", + 'hosts.status_pending': "あなたの返答待ち", + 'hosts.status_approved': "承認済み", + 'hosts.status_refused': "拒否済み", + 'hosts.approve': "承認", + 'hosts.refuse': "拒否", + 'hosts.online': "オンライン", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 83b9eed..87e5b87 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1232,4 +1232,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Hernoemd: ‘{name}’ is niet op elk systeem een geldige naam", 'transfers.renamed_n': "Namen aangepast zodat ze op elk systeem geldig zijn: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Uitnodigingen", + 'home.invitation_hint': "Iemand heeft u aan deze groepen toegevoegd. Accepteer alleen de groepen die u verwacht: een groep waar u lid van wordt, ziet uw adres wanneer u hem opent.", + 'home.invited_by': "uitgenodigd door {name}", + 'home.accept': "Accepteren", + 'home.decline': "Weigeren", + 'members.invited': "uitgenodigd", + 'hosts.title': "Hosts", + 'hosts.hint': "Uw eigen nodes bedienen deze groep zonder te vragen. Een andere node doet dat pas nadat u hem hier goedkeurt; een node die erom vraagt, staat hieronder.", + 'hosts.none': "Geen andere node heeft gevraagd deze groep te hosten.", + 'hosts.from': "Een node van {name}", + 'hosts.status_pending': "wacht op uw antwoord", + 'hosts.status_approved': "goedgekeurd", + 'hosts.status_refused': "geweigerd", + 'hosts.approve': "Goedkeuren", + 'hosts.refuse': "Weigeren", + 'hosts.online': "online", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 3edba2d..6d81b25 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1258,4 +1258,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Zmieniono nazwę: „{name}” nie jest prawidłową nazwą w każdym systemie", 'transfers.renamed_n': "Nazwy zmienione, by były prawidłowe w każdym systemie: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Zaproszenia", + 'home.invitation_hint': "Ktoś dodał Cię do tych grup. Akceptuj tylko te, których się spodziewasz: grupa, do której dołączysz, widzi Twój adres, gdy ją otwierasz.", + 'home.invited_by': "zaprasza {name}", + 'home.accept': "Akceptuj", + 'home.decline': "Odrzuć", + 'members.invited': "zaproszony", + 'hosts.title': "Hosty", + 'hosts.hint': "Twoje własne node'y obsługują tę grupę bez pytania. Inny node robi to dopiero po Twojej akceptacji tutaj; node, który o to prosi, jest widoczny poniżej.", + 'hosts.none': "Żaden inny node nie prosił o hostowanie tej grupy.", + 'hosts.from': "Node użytkownika {name}", + 'hosts.status_pending': "czeka na Twoją odpowiedź", + 'hosts.status_approved': "zaakceptowany", + 'hosts.status_refused': "odrzucony", + 'hosts.approve': "Akceptuj", + 'hosts.refuse': "Odrzuć", + 'hosts.online': "online", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 3f44570..7b12ea5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -1217,4 +1217,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Renomeado: “{name}” não é um nome válido em todos os sistemas", 'transfers.renamed_n': "Nomes alterados para serem válidos em todos os sistemas: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Convites", + 'home.invitation_hint': "Alguém adicionou você a estes grupos. Aceite apenas os que espera: um grupo em que você entra vê o seu endereço quando você o abre.", + 'home.invited_by': "convidado por {name}", + 'home.accept': "Aceitar", + 'home.decline': "Recusar", + 'members.invited': "convidado", + 'hosts.title': "Hosts", + 'hosts.hint': "Seus próprios nodes servem este grupo sem perguntar. Outro node só o serve depois que você o aprova aqui; um node que pede aparece abaixo.", + 'hosts.none': "Nenhum outro node pediu para hospedar este grupo.", + 'hosts.from': "Um node de {name}", + 'hosts.status_pending': "aguardando sua resposta", + 'hosts.status_approved': "aprovado", + 'hosts.status_refused': "recusado", + 'hosts.approve': "Aprovar", + 'hosts.refuse': "Recusar", + 'hosts.online': "online", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 1168460..9f3c902 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -1203,4 +1203,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "已重命名:“{name}”并非在所有系统上都是有效的名称", 'transfers.renamed_n': "为在所有系统上有效而修改的名称:{n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "邀请", + 'home.invitation_hint': "有人把你加入了这些群组。只接受你预期的邀请:你加入的群组在你打开它时可以看到你的地址。", + 'home.invited_by': "由 {name} 邀请", + 'home.accept': "接受", + 'home.decline': "拒绝", + 'members.invited': "已邀请", + 'hosts.title': "主机", + 'hosts.hint': "你自己的 node 无需询问即可提供此群组。其他 node 只有在你于此处批准后才会提供;提出申请的 node 列在下方。", + 'hosts.none': "没有其他 node 申请托管此群组。", + 'hosts.from': "{name} 的 node", + 'hosts.status_pending': "等待你的答复", + 'hosts.status_approved': "已批准", + 'hosts.status_refused': "已拒绝", + 'hosts.approve': "批准", + 'hosts.refuse': "拒绝", + 'hosts.online': "在线", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 98d010e..04c2d23 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -653,7 +653,10 @@ class MeshBayTransport { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this._accessToken}`, }, - body: JSON.stringify({ node_pk: this._nodePkTarget || '' }), + // The token names this connection's group and no other: it is handed to + // the node's operator, who has no business learning every group this + // account belongs to. + body: JSON.stringify({ node_pk: this._nodePkTarget || '', group_id: this._groupId }), }); if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`); return (await r.json()).mnp_token; diff --git a/packages/meshbay-hub/tests/harness/group_hosts_probe.py b/packages/meshbay-hub/tests/harness/group_hosts_probe.py new file mode 100644 index 0000000..0611e3d --- /dev/null +++ b/packages/meshbay-hub/tests/harness/group_hosts_probe.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +A group owner's settings: who was invited, and which other nodes asked to host. + +Renders the shipped `GroupSettingsPanel` with `fetch` stubbed: one member, one +unanswered invitation, one node asking to host and one already approved. Then +clicks Approve on the request and reports what reached the hub. + + group_hosts_probe.py [--engine chrome|firefox] + +Prints JSON. +""" + +import argparse +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8773 +RECORDS = [] +FINISHED = threading.Event() +socketserver.TCPServer.allow_reuse_address = True + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body> +<div id="root"></div> +<script type="module"> +import { html, render } from '/vendor/htm-preact.js'; +import { initLocale } from '/i18n.js'; +import { GroupSettingsPanel } from '/group-settings.js'; + +const realFetch = window.fetch.bind(window); +const calls = []; +const json = (body) => ({ ok: true, status: 200, statusText: '', headers: new Headers(), + json: async () => body, text: async () => JSON.stringify(body) }); +window.fetch = async (url, init = {}) => { + const u = String(url); + calls.push(`${init.method || 'GET'} ${u.replace(/^https?:\/\/[^/]+/, '')}`); + if (u.endsWith('/v1/groups/g1/members')) return json({ + group_id: 'g1', admin_id: 'u1', + members: [{ user_id: 'u1', username: 'the-owner' }], + invited: [{ user_id: 'u9', username: 'someone_asked' }] }); + if (u.endsWith('/v1/groups/g1/hosts')) return json({ hosts: [ + { node_id: 'n-asking', pk_node: 'AAAA', username: 'a-member', status: 'pending', + online: true }, + { node_id: 'n-ok', pk_node: 'BBBB', username: 'another-member', status: 'approved', + online: false }] }); + return json({}); +}; + +await initLocale(); +render(html`<${GroupSettingsPanel} groupId="g1" token="t" userId="u1" + group=${{ id: 'g1', name: 'a group', owner_username: 'the-owner', is_admin: true }} + transportRef=${{ current: null }} gekRef=${{ current: null }} + isNodeAdmin=${false} operatorPaired=${false} connected=${false} + enabledApps=${[]} entries=${[]} nodeDirs=${[]} />`, document.getElementById('root')); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const out = {}; +try { + await wait(1200); + const rows = [...document.querySelectorAll('.admin-table tr')].map((r) => r.innerText); + out.invited_row = rows.some((r) => r.includes('someone_asked')); + out.host_rows = rows.filter((r) => r.includes('AAAA') || r.includes('BBBB')).length; + const asking = [...document.querySelectorAll('.admin-table tr')] + .find((r) => r.innerText.includes('AAAA')); + out.buttons_on_request = asking ? asking.querySelectorAll('button').length : -1; + const approved = [...document.querySelectorAll('.admin-table tr')] + .find((r) => r.innerText.includes('BBBB')); + out.buttons_on_approved = approved ? approved.querySelectorAll('button').length : -1; + if (asking) { asking.querySelector('button').click(); await wait(800); } + out.decision = calls.filter((c) => c.includes('/hosts/')); +} catch (e) { + out.error = String(e && e.stack || e); +} +realFetch('/log', { method: 'POST', body: JSON.stringify(out) }); +</script>__HOLD__</body></html>""" + +HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">' +HOLD = "" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + FINISHED.set() + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/hold": + FINISHED.wait(60) + self._send(b"", "image/gif") + elif path == "/": + self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +# Launchers and profile rule: see sticky_header_probe.py. +ENGINES = { + "chrome": lambda profile: [ + "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900"], + "firefox": lambda profile: [ + "firefox", "--headless", "--profile", profile, + "--screenshot", str(Path(profile) / "shot.png"), "--window-size", "1100,900"], +} + + +def main() -> int: + global HOLD + ap = argparse.ArgumentParser() + ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") + args = ap.parse_args() + HOLD = HOLD_TAG if args.engine == "firefox" else "" + parent = None + if args.engine == "firefox": + snap = Path.home() / "snap" / "firefox" / "common" + parent = str(snap if snap.is_dir() else Path.home()) + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, + prefix="meshbay-probe-", dir=parent) as profile: + proc = subprocess.Popen(ENGINES[args.engine](profile) + + [f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(dict(RECORDS[0], engine=args.engine), indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/harness/invitation_probe.py b/packages/meshbay-hub/tests/harness/invitation_probe.py new file mode 100644 index 0000000..29133ae --- /dev/null +++ b/packages/meshbay-hub/tests/harness/invitation_probe.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +An invitation waiting on the home page, answered in the real application. + +Being added to a group is an invitation until it is accepted (AV33). What only +the running application shows is that the home page lists it, that Accept and +Decline reach the hub, and that an accepted group then appears among the +reader's groups — while a declined one does not. + +Loads the shipped `app.js` with `fetch` stubbed, once per case: + + accept — the invitation is listed, Accept is clicked + decline — the invitation is listed, Decline is clicked + + invitation_probe.py [--engine chrome|firefox] + +Prints JSON: one object per case. +""" + +import argparse +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8772 +RECORDS = [] +FINISHED = threading.Event() +socketserver.TCPServer.allow_reuse_address = True + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body> +<div id="app"></div> +<script type="module"> +const CASE = new URLSearchParams(location.search).get('case'); +const realFetch = window.fetch.bind(window); +const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) }); +const calls = []; +let accepted = false; +const json = (body, status = 200) => ({ + ok: status < 400, status, statusText: '', headers: new Headers(), + json: async () => body, text: async () => JSON.stringify(body), +}); +const GROUP_ROW = { id: '__GROUP__', name: 'Some Group', owner_username: 'the-owner', + visibility: 'private', join_policy: 'invite', is_admin: false, muted: false, + created_at: '2026-09-30T00:00:00+00:00', last_activity_at: '2026-09-30T00:00:00+00:00', + hosted: true, node_online: false, description: '' }; +window.fetch = async (url, init = {}) => { + const u = String(url); + calls.push({ url: u, method: init.method || 'GET' }); + if (u.includes('/v1/users/me/preferences')) return json({}); + if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' }); + if (u.includes('/v1/groups/mine')) return json({ groups: accepted ? [GROUP_ROW] : [] }); + if (u.includes('/v1/groups/invitations')) return json({ invitations: [{ + group_id: '__GROUP__', name: 'Some Group', owner_username: 'the-owner', + invited_by: 'the-owner', created_at: '2026-09-30T00:00:00+00:00' }] }); + if (u.includes('/invitation/accept')) { accepted = true; return json({ status: 'joined' }); } + if (u.includes('/invitation/decline')) return json({ status: 'declined' }); + if (u.includes('/v1/notifications')) return json({ notifications: [], unread_count: 0 }); + return json({}); +}; +localStorage.setItem('mb_auth', JSON.stringify({ + username: 'invitee-account', userId: 'u-1', token: 'tok', refreshToken: 'ref', + role: 'user' })); +history.replaceState(null, '', '/?case=' + CASE + '#/'); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const text = () => document.getElementById('app').innerText; +(async () => { + const out = { case: CASE }; + try { + await import('/app.js'); + await wait(1500); + out.listed = text().includes('Some Group'); + // By position, not by label: the browser's language picks the label. + const buttons = [...document.querySelectorAll('.invite-links li button')]; + out.buttons = buttons.length; + const button = buttons[CASE === 'accept' ? 0 : 1]; + if (button) { button.click(); await wait(1200); } + out.answer_call = calls.filter((c) => c.url.includes('/invitation/')) + .map((c) => `${c.method} ${c.url.replace(/^https?:\/\/[^/]+/, '')}`); + out.mine_refetched = calls.filter((c) => c.url.includes('/v1/groups/mine')).length; + out.invitation_still_shown = document.querySelectorAll('.invite-links li').length > 0; + out.group_card = [...document.querySelectorAll('a.group-card')] + .some((a) => a.getAttribute('href') === '#/group/__GROUP__'); + } catch (e) { + out.error = String(e && e.stack || e); + } + post(out); +})(); +</script>__HOLD__</body></html> +""".replace("__GROUP__", GROUP) + +HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">' +HOLD = "" + + +class H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + FINISHED.set() + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/hold": + FINISHED.wait(60) + self._send(b"", "image/gif") + return + if path == "/": + self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8") + return + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else ( + "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream") + self._send(asset.read_bytes(), ctype) + + +# Same launchers and the same profile rule as sticky_header_probe.py, which +# explains both: Firefox is a snap here, and needs a profile under $HOME. +ENGINES = { + "chrome": lambda profile: [ + "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}"], + "firefox": lambda profile: [ + "firefox", "--headless", "--profile", profile, + "--screenshot", str(Path(profile) / "shot.png")], +} + + +def _profile_parent(engine: str) -> str | None: + if engine != "firefox": + return None + snap = Path.home() / "snap" / "firefox" / "common" + return str(snap if snap.is_dir() else Path.home()) + + +def _run(engine: str, case: str) -> dict | None: + before = len(RECORDS) + FINISHED.clear() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, prefix="meshbay-probe-", + dir=_profile_parent(engine)) as profile: + proc = subprocess.Popen( + ENGINES[engine](profile) + [f"http://127.0.0.1:{PORT}/?case={case}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if len(RECORDS) > before: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return RECORDS[before] if len(RECORDS) > before else None + + +def main() -> int: + global HOLD + ap = argparse.ArgumentParser() + ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") + args = ap.parse_args() + HOLD = HOLD_TAG if args.engine == "firefox" else "" + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + results = [_run(args.engine, "accept"), _run(args.engine, "decline")] + if not all(results): + print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr) + return 1 + print(json.dumps([dict(r, engine=args.engine) for r in results], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/membership.py b/packages/meshbay-hub/tests/membership.py new file mode 100644 index 0000000..041eb74 --- /dev/null +++ b/packages/meshbay-hub/tests/membership.py @@ -0,0 +1,39 @@ +""" +Making somebody a member, the way the product does it. + +An owner adding a username creates an *invitation*; the account becomes a +member only when it accepts (`POST /v1/groups/{id}/invitation/accept`). Tests +that need a member go through both steps, with a token of the invitee's own — +a shortcut that wrote `GroupMember` directly would test a hub where adding +someone still made them a member without asking, which is the hole this +closed. +""" + +from meshbay_hub.auth import issue_access_token +from meshbay_hub.db.engine import get_session_factory +from meshbay_hub.db.models import User +from sqlalchemy import select + + +async def token_of(username: str) -> str: + async with get_session_factory()() as db: + uid = await db.scalar(select(User.id).where(User.username == username)) + assert uid, f"no such account {username!r}" + return issue_access_token(uid) + + +async def accept_invitation(client, group_id: str, username: str) -> None: + tok = await token_of(username) + r = await client.post(f"/v1/groups/{group_id}/invitation/accept", + headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200, r.text + + +async def add_member(client, group_id: str, username: str, owner_headers: dict): + """Invite, then accept as the invitee. Returns the invitation response.""" + r = await client.post(f"/v1/groups/{group_id}/members/{username}", json={}, + headers=owner_headers) + assert r.status_code in (200, 201), r.text + if r.json().get("status") == "invited": + await accept_invitation(client, group_id, username) + return r diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py index 64c2be8..a31aaff 100644 --- a/packages/meshbay-hub/tests/test_account_deletion.py +++ b/packages/meshbay-hub/tests/test_account_deletion.py @@ -12,6 +12,7 @@ command. import hashlib import pytest +from membership import add_member from meshbay_hub.db.models import GroupMember, Notification, RefreshToken, User from sqlalchemy import select @@ -107,8 +108,7 @@ async def test_deletion_clears_memberships_notifications_and_tokens( g = await client.post("/v1/groups", json={"name": "shared"}, headers={"Authorization": f"Bearer {owner_token}"}) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/member1_test", json={}, - headers={"Authorization": f"Bearer {owner_token}"}) + await add_member(client, gid, 'member1_test', {"Authorization": f"Bearer {owner_token}"}) uid = (await db_session.execute( select(User.id).where(User.username == "member1_test"))).scalar_one() diff --git a/packages/meshbay-hub/tests/test_admin_views.py b/packages/meshbay-hub/tests/test_admin_views.py index da2d3c9..2ac37c9 100644 --- a/packages/meshbay-hub/tests/test_admin_views.py +++ b/packages/meshbay-hub/tests/test_admin_views.py @@ -11,6 +11,7 @@ import base64 import hashlib import pytest +from membership import add_member from meshbay_hub.db.models import User from sqlalchemy import select @@ -78,7 +79,7 @@ async def test_the_member_list_of_a_group_skips_them(client, db_session): g = await client.post("/v1/groups", json={"name": "party"}, headers=owner) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/quitter_test", json={}, headers=owner) + await add_member(client, gid, 'quitter_test', owner) await client.request("DELETE", "/v1/users/me", headers=leaver, json={"auth_key": _auth_key( diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py index e66be31..e6531c4 100644 --- a/packages/meshbay-hub/tests/test_availability_between_members.py +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -24,6 +24,7 @@ import time import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from membership import add_member from meshbay_common.crypto import pk_to_b64 @@ -69,9 +70,8 @@ async def _make_group(client, owner: dict, name: str) -> str: async def _add_member(client, owner: dict, group_id: str, member: dict) -> None: - r = await client.post( - f"/v1/groups/{group_id}/members/{member['username']}", - headers={"Authorization": f"Bearer {owner['token']}"}) + r = await add_member(client, group_id, member['username'], + {"Authorization": f"Bearer {owner['token']}"}) assert r.status_code == 201, r.text diff --git a/packages/meshbay-hub/tests/test_group_description.py b/packages/meshbay-hub/tests/test_group_description.py index b41a7db..98ed3cf 100644 --- a/packages/meshbay-hub/tests/test_group_description.py +++ b/packages/meshbay-hub/tests/test_group_description.py @@ -11,6 +11,7 @@ import base64 import hashlib import pytest +from membership import add_member def _auth_key(password: str, username: str) -> str: @@ -52,7 +53,7 @@ async def test_a_member_cannot(client): owner = await _user(client, "owner2_test") member = await _user(client, "member2_test") gid = await _group(client, owner, name="not-yours") - await client.post(f"/v1/groups/{gid}/members/member2_test", json={}, headers=owner) + await add_member(client, gid, 'member2_test', owner) r = await client.patch(f"/v1/groups/{gid}", json={"description": "mine now"}, headers=member) diff --git a/packages/meshbay-hub/tests/test_group_hosting.py b/packages/meshbay-hub/tests/test_group_hosting.py index c6111f2..7aa6bd1 100644 --- a/packages/meshbay-hub/tests/test_group_hosting.py +++ b/packages/meshbay-hub/tests/test_group_hosting.py @@ -17,6 +17,7 @@ import hashlib from datetime import UTC, datetime, timedelta import pytest +from membership import add_member from meshbay_hub.db.models import Group, GroupMember from meshbay_hub.tasks.cleanup import find_unhosted_groups, prune_unhosted_groups from sqlalchemy import select @@ -72,7 +73,7 @@ async def test_a_member_does_not_see_an_unhosted_group(client): owner = await _user(client, "setup2_test") member = await _user(client, "early_bird") gid = (await _group(client, owner, "premature")).json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/early_bird", json={}, headers=owner) + await add_member(client, gid, 'early_bird', owner) mine = await client.get("/v1/groups/mine", headers=member) assert [g["name"] for g in mine.json()["groups"]] == [] @@ -83,7 +84,7 @@ async def test_a_member_sees_it_once_a_node_has_announced_it(client, db_session) owner = await _user(client, "setup3_test") member = await _user(client, "patient_test") gid = (await _group(client, owner, "ready")).json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/patient_test", json={}, headers=owner) + await add_member(client, gid, 'patient_test', owner) await _mark_hosted(db_session, gid) @@ -174,7 +175,7 @@ async def test_collecting_a_group_takes_its_memberships_with_it(client, db_sessi owner = await _user(client, "reaper5_test") await _user(client, "tagalong") gid = (await _group(client, owner, "doomed")).json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/tagalong", json={}, headers=owner) + await add_member(client, gid, 'tagalong', owner) g = await db_session.get(Group, gid) g.created_at = datetime.now(UTC) - timedelta(days=9) diff --git a/packages/meshbay-hub/tests/test_group_leave_and_quota.py b/packages/meshbay-hub/tests/test_group_leave_and_quota.py index 8af830d..e4efc09 100644 --- a/packages/meshbay-hub/tests/test_group_leave_and_quota.py +++ b/packages/meshbay-hub/tests/test_group_leave_and_quota.py @@ -17,6 +17,7 @@ import hashlib from datetime import UTC, datetime import pytest +from membership import add_member from meshbay_hub.api.groups import MAX_PUBLIC_GROUPS from meshbay_hub.db.models import Group, GroupMember, User from sqlalchemy import select @@ -55,7 +56,7 @@ async def test_a_member_can_leave(client, db_session): owner = await _user(client, "owner1_test") member = await _user(client, "member1_test") gid = await _group(client, owner, "readers") - await client.post(f"/v1/groups/{gid}/members/member1_test", json={}, headers=owner) + await add_member(client, gid, 'member1_test', owner) # Marked hosted, or the member would not see the group in the first place # and the assertion below would hold whether or not leaving worked. @@ -80,7 +81,7 @@ async def test_leaving_removes_only_that_membership_row(client, db_session): owner = await _user(client, "owner2_test") member = await _user(client, "member2_test") gid = await _group(client, owner, "still-here") - await client.post(f"/v1/groups/{gid}/members/member2_test", json={}, headers=owner) + await add_member(client, gid, 'member2_test', owner) await client.post(f"/v1/groups/{gid}/leave", headers=member) @@ -100,8 +101,8 @@ async def test_leaving_does_not_touch_the_account_or_its_other_groups(client, db elsewhere = await _user(client, "owner3b_test") gid = await _group(client, owner, "leaving") other = await _group(client, elsewhere, "staying") - await client.post(f"/v1/groups/{gid}/members/member3_test", json={}, headers=owner) - await client.post(f"/v1/groups/{other}/members/member3_test", json={}, headers=elsewhere) + await add_member(client, gid, 'member3_test', owner) + await add_member(client, other, 'member3_test', elsewhere) await client.post(f"/v1/groups/{gid}/leave", headers=member) @@ -130,7 +131,7 @@ async def test_leaving_twice_is_refused(client): owner = await _user(client, "owner5_test") member = await _user(client, "member5_test") gid = await _group(client, owner, "once") - await client.post(f"/v1/groups/{gid}/members/member5_test", json={}, headers=owner) + await add_member(client, gid, 'member5_test', owner) assert (await client.post(f"/v1/groups/{gid}/leave", headers=member)).status_code == 200 @@ -203,7 +204,7 @@ async def test_the_cap_is_per_owner(client): b = await _user(client, "ownerb2_test") for i in range(MAX_PUBLIC_GROUPS): gid = await _group(client, a, f"a-pub-{i}", visibility="public") - await client.post(f"/v1/groups/{gid}/members/ownerb2_test", json={}, headers=a) + await add_member(client, gid, 'ownerb2_test', a) r = await client.post("/v1/groups", json={"name": "b-first", "visibility": "public", diff --git a/packages/meshbay-hub/tests/test_group_membership.py b/packages/meshbay-hub/tests/test_group_membership.py index ad1b3ab..eb8ad78 100644 --- a/packages/meshbay-hub/tests/test_group_membership.py +++ b/packages/meshbay-hub/tests/test_group_membership.py @@ -11,6 +11,7 @@ import base64 import hashlib import pytest +from membership import add_member from meshbay_hub.db.models import GroupMember, User from sqlalchemy import select @@ -33,8 +34,7 @@ async def _user(client, username, password="a-long-enough-passphrase"): async def _group_with_member(client, owner, member_name, name="crew"): g = await client.post("/v1/groups", json={"name": name}, headers=owner) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/{member_name}", json={}, - headers=owner) + await add_member(client, gid, member_name, owner) return gid @@ -86,7 +86,7 @@ async def test_a_member_cannot_remove_anyone(client): member = await _user(client, "member_y") await _user(client, "victim_y") gid = await _group_with_member(client, owner, "member_y") - await client.post(f"/v1/groups/{gid}/members/victim_y", json={}, headers=owner) + await add_member(client, gid, 'victim_y', owner) r = await client.delete(f"/v1/groups/{gid}/members/victim_y", headers=member) assert r.status_code == 403 diff --git a/packages/meshbay-hub/tests/test_group_purge.py b/packages/meshbay-hub/tests/test_group_purge.py index 40d2d54..c802513 100644 --- a/packages/meshbay-hub/tests/test_group_purge.py +++ b/packages/meshbay-hub/tests/test_group_purge.py @@ -21,13 +21,17 @@ from datetime import UTC, datetime, timedelta import jwt import pytest +from membership import add_member from meshbay_hub.db.models import ( ContentReport, EmailVerification, Group, + GroupHost, + GroupInvitation, GroupInviteLink, GroupMember, IPLog, + Node, Notification, User, ) @@ -36,7 +40,7 @@ from sqlalchemy import delete, func, select, text from sqlalchemy.exc import IntegrityError SEEDED = {"group_members", "notifications", "email_verifications", "content_reports", - "group_invite_links"} + "group_invite_links", "group_invitations", "group_hosts"} def _auth_key(password: str, username: str) -> str: @@ -72,8 +76,7 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name headers={"Authorization": f"Bearer {owner_token}"}) assert r.status_code in (200, 201), r.text gid = r.json()["group_id"] - r = await client.post(f"/v1/groups/{gid}/members/{member}", json={}, - headers={"Authorization": f"Bearer {owner_token}"}) + r = await add_member(client, gid, member, {"Authorization": f"Bearer {owner_token}"}) assert r.status_code in (200, 201), r.text member_id = await _uid(db, member) db.add(Notification(user_id=member_id, kind="chat", group_id=gid, title="a message")) @@ -87,6 +90,14 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name email_masked="m***@e***.com", expires_at=datetime.now(UTC) + timedelta(days=1), redeemed_by=member_id, redeemed_at=datetime.now(UTC))) + # An unanswered invitation, and a node asking to host. + invitee = (await db.execute(select(User.id).where(User.id != owner_id, + User.id != member_id))).scalars().first() + db.add(GroupInvitation(group_id=gid, user_id=invitee or owner_id, invited_by=owner_id)) + node = Node(user_id=member_id, pk_node=gid[:43]) + db.add(node) + await db.flush() + db.add(GroupHost(group_id=gid, node_id=node.id, status="pending")) await db.commit() return gid diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 162b074..378bbb8 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -3,13 +3,14 @@ Integration tests for the Hub API. Uses SQLite in-memory + httpx.AsyncClient — no PostgreSQL, no network. """ -from meshbay_common.tokens import HUB_API_AUD from datetime import UTC import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from membership import add_member from meshbay_common.crypto import pk_to_b64 +from meshbay_common.tokens import HUB_API_AUD from meshbay_hub.api.deps import set_admin_usernames @@ -272,8 +273,7 @@ async def test_group_member_add(client): group_id = r.json()["group_id"] # Add bob as member (hub handles membership only, GEK exchange is P2P) - r = await client.post(f"/v1/groups/{group_id}/members/bob2_test", - json={}, headers=a_hdrs) + r = await add_member(client, group_id, 'bob2_test', a_hdrs) assert r.status_code == 201 # Verify bob is in the group @@ -314,8 +314,7 @@ async def test_non_admin_cannot_add_member(client): group_id = r.json()["group_id"] # Dan (non-admin) tries to add a member → 403 - r = await client.post(f"/v1/groups/{group_id}/members/charlie_test", - json={}, + r = await client.post(f"/v1/groups/{group_id}/members/charlie_test", json={}, headers={"Authorization": f"Bearer {dan_token}"}) assert r.status_code == 403 @@ -352,9 +351,7 @@ async def test_jwt_contains_groups_claim(client): headers={"Authorization": f"Bearer {alice_token}"}) group_id = r.json()["group_id"] - await client.post(f"/v1/groups/{group_id}/members/grp_bob_test", - json={}, - headers={"Authorization": f"Bearer {alice_token}"}) + await add_member(client, group_id, 'grp_bob_test', {"Authorization": f"Bearer {alice_token}"}) # Login again — groups should contain the new group r = await client.post("/v1/users/login", json={ @@ -400,9 +397,7 @@ async def test_my_groups(client, db_session): r = await client.post("/v1/groups", json={"name": "mg-group"}, headers={"Authorization": f"Bearer {alice_token}"}) group_id = r.json()["group_id"] - await client.post(f"/v1/groups/{group_id}/members/mg_bob_test", - json={}, - headers={"Authorization": f"Bearer {alice_token}"}) + await add_member(client, group_id, 'mg_bob_test', {"Authorization": f"Bearer {alice_token}"}) # A group no node has announced is shown to its owner only — a member would # otherwise see a name they cannot open. Stamped here so the rest of this diff --git a/packages/meshbay-hub/tests/test_invitation_ui.py b/packages/meshbay-hub/tests/test_invitation_ui.py new file mode 100644 index 0000000..99538e9 --- /dev/null +++ b/packages/meshbay-hub/tests/test_invitation_ui.py @@ -0,0 +1,66 @@ +""" +The two answers a person now gives, in the real application, in both engines. + +An invitee answers an invitation on the home page (harness/invitation_probe.py); +a group owner answers a node that asked to host the group, and sees who was +invited and has not answered (harness/group_hosts_probe.py). The hub side is +`test_invitations_and_hosts.py`; this is where it meets the interface. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" +BINARY = {"chrome": "google-chrome", "firefox": "firefox"} + + +def _run(probe: str, engine: str): + if shutil.which(BINARY[engine]) is None: + pytest.skip(f"{engine} is not available") + proc = subprocess.run([sys.executable, str(HARNESS / probe), "--engine", engine], + capture_output=True, text=True, timeout=240) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module", params=["chrome", "firefox"]) +def invitation(request): + return {c["case"]: c for c in _run("invitation_probe.py", request.param)} + + +@pytest.fixture(scope="module", params=["chrome", "firefox"]) +def hosts(request): + return _run("group_hosts_probe.py", request.param) + + +def test_an_invitation_is_listed_with_its_two_answers(invitation): + for c in invitation.values(): + assert "error" not in c, c["error"] + assert c["listed"] and c["buttons"] == 2 + + +def test_accepting_joins_and_shows_the_group(invitation): + c = invitation["accept"] + assert c["answer_call"] == [ + "POST /v1/groups/0f8fad5b-d9cb-469f-a165-70867728950e/invitation/accept"] + assert c["group_card"] and not c["invitation_still_shown"] + + +def test_declining_leaves_no_group(invitation): + c = invitation["decline"] + assert c["answer_call"][0].endswith("/invitation/decline") + assert not c["group_card"] and not c["invitation_still_shown"] + + +def test_the_owner_sees_the_invited_and_approves_a_host(hosts): + assert "error" not in hosts, hosts.get("error") + assert hosts["invited_row"] + assert hosts["host_rows"] == 2 + assert hosts["buttons_on_request"] == 2 # approve and refuse + assert hosts["buttons_on_approved"] == 1 # refuse only + assert hosts["decision"] == ["POST /v1/groups/g1/hosts/n-asking"] diff --git a/packages/meshbay-hub/tests/test_invitations_and_hosts.py b/packages/meshbay-hub/tests/test_invitations_and_hosts.py new file mode 100644 index 0000000..6460d09 --- /dev/null +++ b/packages/meshbay-hub/tests/test_invitations_and_hosts.py @@ -0,0 +1,204 @@ +""" +Two things one participant must not be able to decide for another. + +**That you are in a group.** An owner could add any username and the account +became a member at once: the group was in its sidebar, named in its MNP tokens, +and its client dialled the group's nodes — nodes the owner chose. So an owner's +addition is an invitation until the invitee accepts it. + +**That a node hosts a group.** A node could register for any group its account +belonged to, and clients keep the first registered node that completes the +handshake — which any member's node does, since every member holds the group +key. So a node hosts a group only if its account owns it or the owner approved +it; the rest of its claim is a request the owner sees. + +Each test is two accounts or more, because a one-member test proves a +one-member property (CLAUDE.md, "ask who pays"). +""" + +import base64 +import time + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from membership import accept_invitation +from meshbay_common.crypto import pk_to_b64 +from meshbay_hub.db.models import GroupHost, Notification +from sqlalchemy import select + +KEY = base64.b64encode(b"k" * 32).decode() + + +async def _user(client, username): + sk = Ed25519PrivateKey.generate() + r = await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.test", "auth_key": KEY}) + assert r.status_code == 201, r.text + uid = r.json()["user_id"] + r = await client.post("/v1/users/login", json={"username": username, "auth_key": KEY}) + return {"id": uid, "name": username, "sk": sk, "pk": pk_to_b64(sk.public_key()), + "H": {"Authorization": f"Bearer {r.json()['access_token']}"}} + + +async def _group(client, owner, name="family"): + """A group that a node already hosts: `/mine` hides an unhosted group from + everyone but its owner, which would make "not in /mine" prove nothing.""" + from meshbay_hub.api.revocation import _mark_hosted + r = await client.post("/v1/groups", headers=owner["H"], + json={"name": name, "visibility": "private", "join_policy": "invite"}) + gid = r.json()["group_id"] + await _mark_hosted([gid]) + return gid + + +async def _node(client, user): + ts = int(time.time()) + msg = f"meshbay:node_announce:{user['id']}:{user['pk']}:{ts}".encode() + r = await client.post("/v1/nodes/announce", headers=user["H"], json={ + "pk_node": user["pk"], "timestamp": ts, + "signature": base64.b64encode(user["sk"].sign(msg)).decode()}) + assert r.status_code == 201, r.text + return r.json()["node_id"] + + +def _node_token(user): + from meshbay_hub.auth import issue_access_token + return issue_access_token(user["id"], scope="node") + + +async def _mine(client, user): + return [g["id"] for g in (await client.get("/v1/groups/mine", + headers=user["H"])).json()["groups"]] + + +# ── Invitations ────────────────────────────────────────────────────────────── + +async def test_being_added_is_an_invitation_not_a_membership(client): + owner, invitee = await _user(client, "inv_owner"), await _user(client, "inv_guest") + gid = await _group(client, owner) + + r = await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) + assert r.json()["status"] == "invited" + + assert gid not in await _mine(client, invitee) + r = await client.get(f"/v1/groups/{gid}/nodes", headers=invitee["H"]) + assert r.status_code == 403, "an invitee must not be handed a node to dial" + listed = (await client.get("/v1/groups/invitations", headers=invitee["H"])).json() + assert [i["group_id"] for i in listed["invitations"]] == [gid] + + +async def test_accepting_makes_a_member_and_declining_leaves_nothing(client): + owner = await _user(client, "acc_owner") + yes, no = await _user(client, "acc_yes_user"), await _user(client, "acc_no_user") + gid = await _group(client, owner) + for u in (yes, no): + await client.post(f"/v1/groups/{gid}/members/{u['name']}", headers=owner["H"]) + + await accept_invitation(client, gid, yes["name"]) + r = await client.post(f"/v1/groups/{gid}/invitation/decline", headers=no["H"]) + assert r.status_code == 200 + + assert gid in await _mine(client, yes) + assert gid not in await _mine(client, no) + assert (await client.get("/v1/groups/invitations", headers=no["H"])).json()[ + "invitations"] == [] + r = await client.post(f"/v1/groups/{gid}/invitation/accept", headers=no["H"]) + assert r.status_code == 404, "a declined invitation cannot be accepted afterwards" + + +async def test_nobody_else_can_accept_an_invitation(client): + owner, invitee = await _user(client, "only_owner"), await _user(client, "only_guest") + other = await _user(client, "only_other") + gid = await _group(client, owner) + await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) + r = await client.post(f"/v1/groups/{gid}/invitation/accept", headers=other["H"]) + assert r.status_code == 404 + assert gid not in await _mine(client, other) + + +async def test_the_owner_sees_and_can_take_back_an_invitation(client): + owner, invitee = await _user(client, "back_owner"), await _user(client, "back_guest") + member = await _user(client, "back_member") + gid = await _group(client, owner) + await client.post(f"/v1/groups/{gid}/members/{invitee['name']}", headers=owner["H"]) + await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"]) + await accept_invitation(client, gid, member["name"]) + + seen = (await client.get(f"/v1/groups/{gid}/members", headers=owner["H"])).json() + assert [u["username"] for u in seen["invited"]] == [invitee["name"]] + # Another member is not told who was asked. + assert "invited" not in (await client.get(f"/v1/groups/{gid}/members", + headers=member["H"])).json() + + r = await client.delete(f"/v1/groups/{gid}/members/{invitee['name']}", + headers=owner["H"]) + assert r.status_code == 200 + assert (await client.get("/v1/groups/invitations", headers=invitee["H"])).json()[ + "invitations"] == [] + + +# ── Hosts ──────────────────────────────────────────────────────────────────── + +async def test_a_members_node_does_not_host_a_group_it_does_not_own(client, db_session): + from meshbay_hub.api.revocation import resolve_node_groups + + owner, member = await _user(client, "host_owner"), await _user(client, "host_member") + gid = await _group(client, owner) + await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"]) + await accept_invitation(client, gid, member["name"]) + member_node = await _node(client, member) + owner_node = await _node(client, owner) + + assert await resolve_node_groups(member_node, member["id"], [gid]) == [] + assert await resolve_node_groups(owner_node, owner["id"], [gid]) == [gid] + + row = await db_session.get(GroupHost, (gid, member_node)) + assert row is not None and row.status == "pending" + notes = (await db_session.execute(select(Notification).where( + Notification.user_id == owner["id"], Notification.kind == "host_request"))).all() + assert len(notes) == 1 + + +async def test_the_owner_approves_a_host_and_can_take_it_back(client, db_session): + from meshbay_hub.api import revocation + + owner, member = await _user(client, "appr_owner"), await _user(client, "appr_member") + gid = await _group(client, owner) + await client.post(f"/v1/groups/{gid}/members/{member['name']}", headers=owner["H"]) + await accept_invitation(client, gid, member["name"]) + node = await _node(client, member) + await revocation.resolve_node_groups(node, member["id"], [gid]) + + # A connected node, as the socket handler records it. + revocation._connected_nodes[node] = object() + revocation._node_claims[node] = [gid] + revocation._node_users[node] = member["id"] + try: + # Not the member's call to make. + r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=member["H"]) + assert r.status_code == 403 + + hosts = (await client.get(f"/v1/groups/{gid}/hosts", headers=owner["H"])).json() + assert [(h["node_id"], h["status"]) for h in hosts["hosts"]] == [(node, "pending")] + + r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"]) + assert r.status_code == 200 + assert revocation._node_groups[node] == [gid], "approval must apply at once" + + r = await client.delete(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"]) + assert r.status_code == 200 + assert revocation._node_groups[node] == [] + # Refused, and asking again does not notify the owner a second time. + await revocation.resolve_node_groups(node, member["id"], [gid]) + notes = (await db_session.execute(select(Notification).where( + Notification.user_id == owner["id"], Notification.kind == "host_request"))).all() + assert len(notes) == 1 + finally: + revocation.forget_node(node) + + +async def test_only_a_node_that_asked_can_be_approved(client): + owner, member = await _user(client, "ask_owner"), await _user(client, "ask_member") + gid = await _group(client, owner) + node = await _node(client, member) + r = await client.post(f"/v1/groups/{gid}/hosts/{node}", headers=owner["H"]) + assert r.status_code == 404 diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py index 3aa3115..ef6423d 100644 --- a/packages/meshbay-hub/tests/test_mnp_token.py +++ b/packages/meshbay-hub/tests/test_mnp_token.py @@ -8,9 +8,7 @@ these tests pin that it authorises to a node and is refused by the hub API. """ import pytest - from meshbay_common.handshake import HandshakeError, authorize_token -from meshbay_common.tokens import MNP_AUD async def _session_token(client, username="mnp_user_test"): @@ -21,6 +19,12 @@ async def _session_token(client, username="mnp_user_test"): return r.json()["access_token"] +async def _own_group(client, tok, name="g"): + return (await client.post("/v1/groups", headers={"Authorization": f"Bearer {tok}"}, + json={"name": name, "visibility": "private", + "join_policy": "invite"})).json()["group_id"] + + @pytest.mark.asyncio async def test_mnp_token_endpoint_needs_a_session(client): # No Authorization header at all — FastAPI rejects the required header (422), @@ -32,7 +36,8 @@ async def test_mnp_token_endpoint_needs_a_session(client): @pytest.mark.asyncio async def test_mnp_token_is_minted_for_a_member(client): tok = await _session_token(client) - r = await client.post("/v1/nodes/mnp-token", + gid = await _own_group(client, tok) + r = await client.post("/v1/nodes/mnp-token", json={"group_id": gid}, headers={"Authorization": f"Bearer {tok}"}) assert r.status_code == 200 assert r.json().get("mnp_token") @@ -42,7 +47,8 @@ async def test_mnp_token_is_minted_for_a_member(client): async def test_mnp_token_is_refused_at_the_hub_api(client): """The whole point: the credential a node receives opens nothing at the hub.""" tok = await _session_token(client, "mnp_api_test") - mnp = (await client.post("/v1/nodes/mnp-token", + gid = await _own_group(client, tok) + mnp = (await client.post("/v1/nodes/mnp-token", json={"group_id": gid}, headers={"Authorization": f"Bearer {tok}"})).json()["mnp_token"] # Presenting it to a hub endpoint fails. r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {mnp}"}) @@ -60,7 +66,8 @@ async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(cli H = {"Authorization": f"Bearer {tok}"} gid = (await client.post("/v1/groups", headers=H, json={ "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"] - mnp = (await client.post("/v1/nodes/mnp-token", headers=H)).json()["mnp_token"] + mnp = (await client.post("/v1/nodes/mnp-token", headers=H, + json={"group_id": gid})).json()["mnp_token"] pk = hub_public_key_pem() # The session token is refused by the node handshake (wrong audience). @@ -83,10 +90,49 @@ async def test_the_mnp_token_is_bound_to_the_node_it_names(client): "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"] # A token bound to node A's key. mnp = (await client.post("/v1/nodes/mnp-token", headers=H, - json={"node_pk": "node-A-pk"})).json()["mnp_token"] + json={"node_pk": "node-A-pk", "group_id": gid})).json()["mnp_token"] pk = hub_public_key_pem() # Node B refuses it; node A accepts it. with pytest.raises(HandshakeError, match="this node"): authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-B-pk") peer = authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-A-pk") assert peer.group_id == gid + + +@pytest.mark.asyncio +async def test_the_mnp_token_names_only_the_group_asked_for(client): + """It is handed to that group's node operator, who has no business learning + every other group the member belongs to.""" + import jwt as _jwt + + tok = await _session_token(client, "mnp_scope_test") + H = {"Authorization": f"Bearer {tok}"} + one = await _own_group(client, tok, "one") + await _own_group(client, tok, "two") + await _own_group(client, tok, "three") + mnp = (await client.post("/v1/nodes/mnp-token", headers=H, + json={"group_id": one})).json()["mnp_token"] + claims = _jwt.decode(mnp, options={"verify_signature": False}) + assert claims["groups"] == [one] + + +@pytest.mark.asyncio +async def test_no_group_is_named_for_a_non_member(client): + from meshbay_hub.auth import hub_public_key_pem + + owner = await _session_token(client, "mnp_owner_test") + gid = await _own_group(client, owner) + stranger = await _session_token(client, "mnp_stranger") + mnp = (await client.post("/v1/nodes/mnp-token", json={"group_id": gid}, + headers={"Authorization": f"Bearer {stranger}"})).json()["mnp_token"] + with pytest.raises(HandshakeError) as refused: + authorize_token(mnp, hub_public_key_pem(), group_id=gid) + assert refused.value.code == "not_a_member" + + +@pytest.mark.asyncio +async def test_a_token_must_name_its_group(client): + tok = await _session_token(client, "mnp_nogroup_test") + r = await client.post("/v1/nodes/mnp-token", json={}, + headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 422 diff --git a/packages/meshbay-hub/tests/test_node_auth.py b/packages/meshbay-hub/tests/test_node_auth.py index 09816de..fb05f9b 100644 --- a/packages/meshbay-hub/tests/test_node_auth.py +++ b/packages/meshbay-hub/tests/test_node_auth.py @@ -11,6 +11,7 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from membership import add_member def _gen_ed25519(): @@ -166,8 +167,7 @@ async def test_node_token_may_add_a_member_to_its_own_operators_group(client): node_token = (await _node_auth(client, "op1_test", sk_op)).json()["access_token"] - r = await client.post(f"/v1/groups/{gid}/members/member1_test", - headers={"Authorization": f"Bearer {node_token}"}) + r = await add_member(client, gid, 'member1_test', {"Authorization": f"Bearer {node_token}"}) assert r.status_code == 201 # …but not to a group it does not own. @@ -175,7 +175,7 @@ async def test_node_token_may_add_a_member_to_its_own_operators_group(client): r = await client.post("/v1/groups", json={"name": "theirs", "visibility": "private"}, headers={"Authorization": f"Bearer {other_token}"}) other_gid = r.json()["group_id"] - r = await client.post(f"/v1/groups/{other_gid}/members/member1_test", + r = await client.post(f"/v1/groups/{other_gid}/members/member1_test", json={}, headers={"Authorization": f"Bearer {node_token}"}) assert r.status_code == 403 diff --git a/packages/meshbay-hub/tests/test_notifications_behaviour.py b/packages/meshbay-hub/tests/test_notifications_behaviour.py index 4684d3f..2d0b273 100644 --- a/packages/meshbay-hub/tests/test_notifications_behaviour.py +++ b/packages/meshbay-hub/tests/test_notifications_behaviour.py @@ -11,6 +11,7 @@ import base64 import hashlib import pytest +from membership import add_member from meshbay_hub.db.models import GroupMember, Notification, User from sqlalchemy import select @@ -40,8 +41,7 @@ async def test_chat_keeps_one_notification_per_group(client, db_session): g = await client.post("/v1/groups", json={"name": "busy"}, headers={"Authorization": f"Bearer {owner}"}) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/listener", json={}, - headers={"Authorization": f"Bearer {owner}"}) + await add_member(client, gid, 'listener', {"Authorization": f"Bearer {owner}"}) uid = (await db_session.execute( select(User.id).where(User.username == "listener"))).scalar_one() @@ -72,8 +72,7 @@ async def test_muting_a_group_stops_notifications_being_created(client, db_sessi g = await client.post("/v1/groups", json={"name": "loud"}, headers={"Authorization": f"Bearer {owner}"}) gid = g.json()["group_id"] - await client.post(f"/v1/groups/{gid}/members/quiet_test", json={}, - headers={"Authorization": f"Bearer {owner}"}) + await add_member(client, gid, 'quiet_test', {"Authorization": f"Bearer {owner}"}) r = await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, headers={"Authorization": f"Bearer {token}"}) @@ -170,8 +169,7 @@ async def test_you_are_not_notified_of_your_own_message(client, db_session): headers={"Authorization": f"Bearer {owner}"}) gid = g.json()["group_id"] for name in ("chatty_test", "quiet_test"): - await client.post(f"/v1/groups/{gid}/members/{name}", json={}, - headers={"Authorization": f"Bearer {owner}"}) + await add_member(client, gid, name, {"Authorization": f"Bearer {owner}"}) ids = {u.username: u.id for u in (await db_session.execute( select(User).where(User.username.in_(["operator", "chatty_test", "quiet_test"])) |