aboutsummaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--CLAUDE.md2
-rw-r--r--docs/MESHBAY_DESIGN.md34
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md6
-rw-r--r--docs/USERGUIDE.md21
-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
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/members.py4
-rw-r--r--packages/meshbay-node/tests/golden/cli.json2
44 files changed, 1573 insertions, 95 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index cc79d43..dfe0e49 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -999,6 +999,8 @@ here are kept only where they are a rule about *editing* the code.
| Flow-control worst case | `packages/meshbay-hub/tests/harness/window_leak.mjs` |
| Session renewal against a hub that enforces rotation | `packages/meshbay-hub/tests/harness/session_harness.mjs` |
| An invitation link opened in the real app, signed out and in | `packages/meshbay-hub/tests/harness/invite_link_probe.py` |
+| An invitation answered on the home page (accept, decline), in both engines | `packages/meshbay-hub/tests/harness/invitation_probe.py` |
+| A group owner's invited list and host approvals, in both engines | `packages/meshbay-hub/tests/harness/group_hosts_probe.py` |
| A show's modal and the player, walked episode to episode | `packages/meshbay-hub/tests/harness/video_series_probe.py` |
| A lazily loaded view whose module answers 404 (a tab older than the deploy) | `packages/meshbay-hub/tests/harness/lazy_failure_probe.py` |
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 6e90402..78a3261 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -999,13 +999,21 @@ already fails the key proof. Pinning covers the case where an attacker *holds* t
group key — an ex-member, a leaked key — and swaps the node underneath, which the
proof alone cannot distinguish from the genuine node.
+What pinning does not cover is a **second node** holding the key: the pin is per
+node, and a node with its own identity is a first sight. A member's node is exactly
+that — every member holds the group key — so what keeps it from being offered to
+clients as the group's host is the hub's registration rule (§7.2, **AV32**), not the
+pin.
+
**The token the member presents is a node-audience token, never the hub session
token (E10).** A member hands whatever it presents here to the node operator,
who is in the threat model, so the credential must open nothing at the hub. The
hub signs two audiences with its one key: a session token (`aud` = the hub API)
for `hubFetch` and signaling, and a short-lived **MNP token** (`aud = MNP_AUD`,
-from `POST /v1/nodes/mnp-token`) that carries the member's `sub`, `groups` and
-`jti` and is the only thing presented in the handshake. The node binds `MNP_AUD`
+from `POST /v1/nodes/mnp-token`) that carries the member's `sub`, `jti` and **the
+one group the connection is for** — never the member's other groups, which the
+operator it is handed to has no business learning — and is the only thing
+presented in the handshake. The node binds `MNP_AUD`
when it decodes, so a session token is refused here; the hub API binds its own
audience and requires `exp`, `sub` and a known `scope`, so an MNP token captured
by an operator is refused there, and so is any other hub-signed token that is not a
@@ -1815,7 +1823,13 @@ account has and when each last signed in.
Registration on the node socket requires a **node-scoped token**, verifies the node
record against the token subject, and **derives group claims from the database**: a
node may narrow the set to what it hosts but cannot widen it, and cannot displace a
-live registration (**C2**). Narrowing goes all the way down: **an empty
+live registration (**C2**). The ceiling is **the groups its account owns, plus those
+whose owner approved that node** (`group_hosts`) — not the groups its account belongs
+to. Every member holds the group key, so a member's node passes the handshake exactly
+as the real host would, and clients keep the first registered node that completes it:
+with membership as the ceiling, any member could stand in for the host. A node that
+claims a group it may not host is recorded as a request, and the owner is notified
+once and approves or refuses it from the group's settings (**AV32**). Narrowing goes all the way down: **an empty
claim is a claim on nothing**, never on everything. Reading it as "all of this
account's groups" made an unconfigured node a registered source for groups it could
not serve — including other members' — and since `/v1/groups/{id}/nodes` answers in
@@ -1924,7 +1938,17 @@ row and nothing else. Answering any authenticated account — as it did while on
the public case was checked — hands whoever knows the group id the identities of
the machines hosting it, and an ex-member knows that id for ever. Nothing needs
it before joining: an open join writes the membership row first, and an
-invitation registers the invitee's when the code is created.
+invitation is accepted before the invitee's client asks for a node.
+
+**Being added to a group is an invitation, not a membership** (**AV33**). An owner
+adding a username — from the Members tab or `meshbay-node member invite` — writes a
+`group_invitations` row; the account becomes a member when it accepts on its home
+page. Until then the group is not in its sidebar or Search, is named in none of its
+tokens, and its nodes refuse it signaling like any non-member's. Without that step
+any account could make any other account's client dial a node of its choosing — and
+learn its address, and receive from it a first-join identity bundle. Redeeming an
+invitation link, joining an open group and creating a group are the account's own
+acts and still write the membership directly.
### 7.4 Instance policy
@@ -3337,6 +3361,8 @@ had already been asked.
| **AV29** | **An invitation link is bounded on both halves and its mail on the sender** (§3.4, §7.3). Twenty outstanding per group on the node (bearer codes) and on the hub (tickets); and because a link mail reaches an address the hub has no relationship with, at the request of anyone who owns a group, it is counted **per sending account per day** (`mail.invite_link_daily_cap`, 10), under the recipient and instance bounds and outside the recovery reserve (`invite_link` is not a recovery purpose) |
| **AV28** | **How many node keys one account may announce is bounded** (§7.2). Each is a row plus an IP-log row under a one-year retention, so an account in a loop writes a year of storage on the operator's disk having paid only for signatures. Proof of possession (**M8**) settles whose key it is and not how many. Counted only where a row is added: re-announcing a key already held keeps working at the ceiling, or a node that reached it could never refresh its address again |
| **AV30** | **What one member's offers cost a node is bounded per account and per node, and the bound admits the heaviest ordinary account** (§7.2). Each offer makes the node allocate a peer connection. A budget of 120 per node refilled at two a second bounds a member there without touching their other nodes, and it is counted by account because a mobile carrier shares one IPv4 address among many subscribers. Pending offers are capped at 32 per account. Both refusals carry `Retry-After` and the client retries them, because a refused offer otherwise reads as a node that is down |
+| **AV32** | **A node hosts a group because its owner said so, not because its account belongs to it** (§7.2). The claim ceiling was membership, and every member holds the key, so any member's node could register as a host and be the first one a client kept. Now: the owner's own nodes, plus nodes the owner approved; anything else is a pending request the owner sees |
+| **AV33** | **Nobody is made a member without saying yes** (§7.3). An owner could add any username, and the account's client then listed the group, named it in its tokens and dialled its nodes. An addition is an invitation until accepted, and the MNP token names only the group it is minted for |
| **AV31** | **What waits for a signature is bounded** (§5.4). Any authenticated member can ask for an admin challenge, since the signature is checked afterwards, and a pending challenge kept its whole request until answered — measured, 200 requests of 1 MiB held 400 MiB for the life of one connection. At most eight pending per connection, 64 KiB each, expired ones dropped |
### 13.6 Chat design findings
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index 2e5aca3..70047b2 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -489,8 +489,10 @@ would let whoever issues tokens decide it instead. Identity keys are pinned by t
node's roster. The hub certifies accounts, not keys.
`not_a_member` means the hub did not count this account a member of the group when it
-minted the token. The MNP token is minted for each connection, from the membership the
-hub holds at that moment, so a stale `groups` claim is no longer the usual cause; the
+minted the token. The MNP token is minted for each connection and names **only** that
+connection's group (`POST /v1/nodes/mnp-token {node_pk, group_id}`; `groups` is empty
+for a non-member), so the operator it is handed to learns nothing of the member's
+other groups. A stale `groups` claim is therefore no longer the usual cause; the
client still refreshes its session once and retries on that code before telling someone
who was just invited that they are not a member.
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index fc9cf40..a802ae1 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -212,7 +212,9 @@ Someone who runs a node invites you. You need an account on the same hub first.
1. **They send you a code** — eight characters like `K7P2-9WQX`, by message,
mail, or read out loud. It is good for 7 days by default, works once, and
only for your account in that one group.
-2. **You sign in**, and the group is already in your sidebar.
+2. **You sign in**, and the group is waiting under **Invitations** on your
+ home page. **Accept** it — until you do, nothing connects to it — and it
+ joins your sidebar. **Decline** if you did not expect it.
3. **You open it.** It says *"This node needs to recognise you"*. Paste the
code.
4. Done — the machine hosting the group recognises you from now on, and the
@@ -566,10 +568,19 @@ meshbay-node member invite alice_dupont
Or the group's **Members** tab, from a paired browser.
-They need an account on the same hub first. The invitation registers their
-membership on the hub and produces a code that never goes near it. Send the
-code out of band; they enter it the first time they open the group. You do not
-need to be online then.
+They need an account on the same hub first. The invitation appears on their
+home page, where they accept it, and produces a code that never goes near the
+hub. Send the code out of band; they enter it the first time they open the
+group. You do not need to be online then.
+
+### Other nodes hosting your group
+
+Your own nodes serve your groups without asking. Any other node — a member's,
+say, offering a second copy — serves one of your groups only after you approve
+it: it appears in the group's settings under **Hosts**, and you are notified
+the first time it asks. **Approve** or **Refuse**; either can be changed later.
+A member's node holds the same group key as everyone, so a node you did not
+approve could otherwise present itself to your members as the group's host.
**Inviting someone who has no account yet** is a link:
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"]))
diff --git a/packages/meshbay-node/src/meshbay_node/cli/members.py b/packages/meshbay-node/src/meshbay_node/cli/members.py
index 5d26bd0..08cff39 100644
--- a/packages/meshbay-node/src/meshbay_node/cli/members.py
+++ b/packages/meshbay-node/src/meshbay_node/cli/members.py
@@ -100,8 +100,8 @@ def member(args) -> None:
print()
print(f"Send it to {args.target} however you normally talk. It works")
print("once, for that account only, and never passes through the hub.")
- print("They enter it the first time they open the group — you do not")
- print("need to be online then.")
+ print("They accept the invitation in MeshBay, then enter the code the")
+ print("first time they open the group — you do not need to be online then.")
print()
print(f"also written to {path}")
return
diff --git a/packages/meshbay-node/tests/golden/cli.json b/packages/meshbay-node/tests/golden/cli.json
index 71fa336..b397eeb 100644
--- a/packages/meshbay-node/tests/golden/cli.json
+++ b/packages/meshbay-node/tests/golden/cli.json
@@ -334,7 +334,7 @@
"asked": [],
"exit": 0,
"stderr": "",
- "stdout": "INVITATION CODE TEST-CODE\nvalid until \n\nSend it to bob however you normally talk. It works\nonce, for that account only, and never passes through the hub.\nThey enter it the first time they open the group — you do not\nneed to be online then.\n\nalso written to <tmp>/home/.local/share/meshbay/invite-code\n",
+ "stdout": "INVITATION CODE TEST-CODE\nvalid until \n\nSend it to bob however you normally talk. It works\nonce, for that account only, and never passes through the hub.\nThey accept the invitation in MeshBay, then enter the code the\nfirst time they open the group — you do not need to be online then.\n\nalso written to <tmp>/home/.local/share/meshbay/invite-code\n",
"systemctl": []
},
"member invite bob@example.test --link": {