aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 11:49:56 +0200
commitd3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 (patch)
tree95ff1252c80a71e93c5098822d31b835572d8b52 /packages/meshbay-hub
parent69554fac7eba6eef7eb8a1c0111c5b92e7f21256 (diff)
downloadmeshbay-d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4.tar.gz
fix: only the owner decides who hosts a group, and nobody is made a member unasked
- hub: a node may host a group only if its account owns it or the owner approved that node (new `group_hosts`). Membership was the ceiling, and every member holds the group key, so any member's node could register as a host and be the one clients kept. A node claiming a group it may not host is recorded as a request; the owner is notified once and approves or refuses it (GET/POST/DELETE /v1/groups/{id}/hosts[/{node_id}]), which takes effect on a connected node at once. - hub: an owner adding a username creates an invitation (new `group_invitations`), accepted or declined by the invitee (/v1/groups/invitations, /{id}/invitation/accept|decline). Until then the group is not listed, not dialled, not searched and not in any token. Invitation links, open joins and group creation still make members directly: they are the account's own act. - hub: the MNP token names only the group it is minted for (group_id is now required), so a node operator no longer learns a member's other groups. - SPA: invitations on the home page; invited people and host requests in the group's settings; the transport sends group_id. Ten catalogues. - Browser probes for both screens, run in Chrome and Firefox. - Design §5.2, §7.2, §7.3, AV32, AV33; protocol §6.3; user guide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py206
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a1b2c3d4e5f7_invitations_and_group_hosts.py49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js74
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js78
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js5
-rw-r--r--packages/meshbay-hub/tests/harness/group_hosts_probe.py175
-rw-r--r--packages/meshbay-hub/tests/harness/invitation_probe.py201
-rw-r--r--packages/meshbay-hub/tests/membership.py39
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py4
-rw-r--r--packages/meshbay-hub/tests/test_admin_views.py3
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py6
-rw-r--r--packages/meshbay-hub/tests/test_group_description.py3
-rw-r--r--packages/meshbay-hub/tests/test_group_hosting.py7
-rw-r--r--packages/meshbay-hub/tests/test_group_leave_and_quota.py13
-rw-r--r--packages/meshbay-hub/tests/test_group_membership.py6
-rw-r--r--packages/meshbay-hub/tests/test_group_purge.py17
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py17
-rw-r--r--packages/meshbay-hub/tests/test_invitation_ui.py66
-rw-r--r--packages/meshbay-hub/tests/test_invitations_and_hosts.py204
-rw-r--r--packages/meshbay-hub/tests/test_mnp_token.py58
-rw-r--r--packages/meshbay-hub/tests/test_node_auth.py6
-rw-r--r--packages/meshbay-hub/tests/test_notifications_behaviour.py10
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"]))