summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md12
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md10
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py6
-rw-r--r--packages/meshbay-common/tests/test_handshake.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js71
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js4
-rw-r--r--packages/meshbay-hub/tests/test_node_ws_auth.py120
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py54
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py10
-rw-r--r--packages/meshbay-node/tests/test_hub_ws_group_claim.py100
11 files changed, 439 insertions, 27 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 39a24da..f125825 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1519,7 +1519,17 @@ 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**).
+live registration (**C2**). 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
+registration order, one such node reaching the hub first made a group unopenable for
+everyone in it (2026-09-11). The ceiling applies to **every** message that changes the
+set, not only to the registration: a node that may narrow on connecting and widen on
+reload has no ceiling.
+
+A client must therefore treat that list as candidates rather than a ranking, and try
+the next node on a `not_hosted` refusal (`MESHBAY_NODE_PROTOCOL.md` §6.3).
The node authenticates to the hub with an Ed25519 signature over a domain-separated
timestamped message — **no password and no auth key on a node** — and receives a
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index f7e8c48..81605c6 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -466,7 +466,7 @@ absent. `verify_proof` compares with `hmac.compare_digest`.
| `group_id` non-empty | `group_id is required` | an absent group means no membership check to make; there is no default group, and a node's first group is not one |
| not on the denylist for `user_id`, `jti` **or** `group_id` | `Token revoked` | all three targets, and persisted to disk: a revocation that a restart forgets is not one |
| `group_id ∈ token.groups` | `Not a member of this group`, code `not_a_member` | the membership check itself — a token is proof of an account, never of a group |
-| `group_id ∈ node.hosted_groups` | `Group not hosted on this node` | |
+| `group_id ∈ node.hosted_groups` | `Group not hosted on this node`, code `not_hosted` | the hub may hand a client several nodes for one group, and only some of them host it |
`AuthorizedPeer` carries `user_id`, `group_id`, `username`, `jti` — and deliberately
**no user public key**. A key arriving in a token would be a key the hub chose, and the
@@ -478,6 +478,14 @@ node's roster. The hub certifies accounts, not keys.
group (`groups` is baked in at login and the hub pushes no updates), so the client
refreshes once and retries on that code rather than telling a member they are not one.
+`not_hosted` is the client's signal to try the **next** node the hub offered for the
+group rather than to report a failure. `/v1/groups/{id}/nodes` returns every node
+registered for the group, in hub registration order, and that order is not a ranking:
+a node listed first is not necessarily one that holds the group's files. Refusing
+without a code made this indistinguishable from a refusal the reader has to act on,
+and a client that stopped at the first node let one wrongly registered peer make a
+group unopenable for all of its members (2026-09-11).
+
### 6.4 Channel binding
| Transport | Anchor | Construction |
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
index 65b4e85..4d2cac2 100644
--- a/packages/meshbay-common/src/meshbay_common/handshake.py
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -278,7 +278,11 @@ def authorize_token(
raise HandshakeError("Not a member of this group", code="not_a_member")
if hosted_groups is not None and group_id not in hosted_groups:
- raise HandshakeError("Group not hosted on this node")
+ # Coded, because a client handed several nodes for one group has to tell
+ # "this node cannot serve it, try the next one" apart from "you, in this
+ # browser, must do something first". Uncoded it was neither, and a node
+ # wrongly registered for a group took that group down for everyone.
+ raise HandshakeError("Group not hosted on this node", code="not_hosted")
return AuthorizedPeer(
user_id=user_id,
diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py
index 8981db8..d4f6d2b 100644
--- a/packages/meshbay-common/tests/test_handshake.py
+++ b/packages/meshbay-common/tests/test_handshake.py
@@ -223,3 +223,37 @@ def test_membership_refusal_carries_a_code_a_client_can_act_on():
with pytest.raises(HandshakeError) as excinfo:
authorize_token(token, pem_pub, group_id="g" * 32)
assert excinfo.value.code == "not_a_member"
+
+
+def test_a_group_this_node_does_not_host_is_refused_with_a_code():
+ """
+ A client is handed every node the hub registered for a group, and only some
+ of them may be able to serve it. Telling "try the next node" apart from
+ "you, here, must do something first" is what this code is for: without it
+ the client either stopped at the first refusal — which is how a group went
+ dark on 2026-09-11 with its real host online — or had to match on wording.
+ """
+ import jwt as _jwt
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+ from cryptography.hazmat.primitives import serialization
+
+ sk = Ed25519PrivateKey.generate()
+ pem_priv = sk.private_bytes(
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption())
+ pem_pub = sk.public_key().public_bytes(
+ serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
+
+ group = "g" * 32
+ token = _jwt.encode(
+ {"sub": "u1", "jti": "j1", "scope": "user", "groups": [group]},
+ pem_priv, algorithm="EdDSA")
+
+ # A member of the group, on a node that does not host it.
+ with pytest.raises(HandshakeError) as excinfo:
+ authorize_token(token, pem_pub, group_id=group, hosted_groups={"other"})
+ assert excinfo.value.code == "not_hosted"
+
+ # And the node that does host it still lets them in.
+ peer = authorize_token(token, pem_pub, group_id=group, hosted_groups={group})
+ assert peer.group_id == group
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 003e396..7c37a1e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -171,6 +171,41 @@ async def _reject(ws: WebSocket, detail: str, code: int) -> None:
await ws.close(code=code)
+async def _authorized_groups(user_id: str) -> set[str]:
+ """The groups this account belongs to — the ceiling on what its nodes may claim.
+
+ Read fresh rather than captured once at registration: a node WebSocket lives
+ for hours, and a group joined in the meantime has to become claimable through
+ `update_groups` without reconnecting.
+ """
+ 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))
+ return {gid for (gid,) in result.all()}
+
+
+def _claimable(claimed_groups, authorized: set[str]) -> list[str]:
+ """What a node actually gets registered for. Two rules.
+
+ A node may only *narrow* the set: `authorized` is the ceiling, or a node
+ could advertise itself as a source for any group on the hub (finding C2).
+
+ And an empty claim means **no groups**, never "all of them". This used to
+ read `set(claimed_groups or authorized)`, so a node hosting nothing — which
+ sends no `group_ids` at all — was registered as a host for every group its
+ owner belonged to, other people's included. Such a node cannot serve any of
+ them: it holds no GEK, and its own handshake refuses them with "Group not
+ hosted on this node". But `/v1/groups/{id}/nodes` returns nodes in
+ registration order, so once one of them won the reconnection race after a
+ hub restart it became `nodes[0]` and captured the group's entire client
+ traffic. Any member could take a group down for everyone, by accident,
+ merely by leaving an unconfigured node running.
+ """
+ return sorted(authorized & set(claimed_groups or ()))
+
+
async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tuple:
"""
Resolve a node WS registration against the database.
@@ -212,8 +247,7 @@ async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tup
select(GroupMember.group_id).where(GroupMember.user_id == user_id))
authorized = {gid for (gid,) in result.all()}
- claimed = set(claimed_groups or authorized)
- return claimed_id, sorted(authorized & claimed)
+ return claimed_id, _claimable(claimed_groups, authorized)
@router.websocket("/v1/nodes/ws")
@@ -286,7 +320,12 @@ async def node_websocket(ws: WebSocket):
from meshbay_hub.api.signaling import handle_webrtc_answer
handle_webrtc_answer(msg)
elif msg.get("type") == "update_groups":
- new_gids = msg.get("group_ids", [])
+ # Through the same gate as the registration above. This used to
+ # 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_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/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index c59d097..18d3a8f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -311,7 +311,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const sessionKeys = null;
setStatus('connecting');
- const nodeId = nodesData.nodes[0].node_id;
// Renewed here rather than taken from the prop. This effect no longer
// re-runs when the token rotates (see the dependency list below), so
// the captured one can be older than the session's — and it is used to
@@ -319,25 +318,59 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// connection at all. Renewals are shared, so if one is already in
// flight this waits for it instead of starting a second.
const live = (await ensureFreshToken()) || token;
- // The same base the API calls use: signaling is a hub endpoint like
- // any other, and two sources for one address is how they drift.
- const transport = new window.MeshBayTransport(HUB, live);
- transportRef.current = transport;
- // Consulted only by the automatic reconnect after a WebRTC failure
- // (transport.js's _reconnectLoop) — the token captured by this
- // connect() call can be stale by then, since the whole point is that
- // some real time (screen lock, a dead NAT mapping) passed unnoticed.
- transport.onNeedToken = async () => (await ensureFreshToken()) || token;
- // Set before connect(), because connect() is where device_hello runs —
- // and again on every reconnect it makes, which is the case this exists
- // for: nothing else tells the page the answer changed.
- transport.onDeviceIdentity = (ok) => {
- if (!cancelled) setDeviceReady(ok);
- };
- const ack = await transport.connect(
- nodeId, live, groupId, null, sessionKeys, session.bundleKey, username,
- userId, session.pendingJoinCode, session.recoveryKey);
+ // Every node the hub lists, in turn — not `nodes[0]` and nothing else.
+ // The list is in hub registration order, and its head is not
+ // necessarily a node that can serve the group: one whose config does
+ // not list it refuses the handshake with "Group not hosted on this
+ // node". Stopping at the first made that refusal indistinguishable
+ // from the group being down, while a node that *could* serve it stood
+ // second in the same list — which is how `media` went dark on
+ // 2026-09-11 with its only real host online the whole time. The hub no
+ // longer registers a node for a group it does not claim; trying the
+ // rest is what keeps one bad entry from being fatal again.
+ // `transport` holds the attempt in progress, and keeps whichever one
+ // answers — so it is null after the loop exactly when none did.
+ let transport = null, ack = null, lastErr = null;
+ for (const n of nodesData.nodes) {
+ // The same base the API calls use: signaling is a hub endpoint like
+ // any other, and two sources for one address is how they drift.
+ transport = new window.MeshBayTransport(HUB, live);
+ transportRef.current = transport;
+ // Consulted only by the automatic reconnect after a WebRTC failure
+ // (transport.js's _reconnectLoop) — the token captured by this
+ // connect() call can be stale by then, since the whole point is that
+ // some real time (screen lock, a dead NAT mapping) passed unnoticed.
+ transport.onNeedToken = async () => (await ensureFreshToken()) || token;
+ // Set before connect(), because connect() is where device_hello runs —
+ // and again on every reconnect it makes, which is the case this exists
+ // for: nothing else tells the page the answer changed.
+ transport.onDeviceIdentity = (ok) => {
+ if (!cancelled) setDeviceReady(ok);
+ };
+ try {
+ ack = await transport.connect(
+ n.node_id, live, groupId, null, sessionKeys, session.bundleKey,
+ username, userId, session.pendingJoinCode, session.recoveryKey);
+ break;
+ } catch (e) {
+ lastErr = e;
+ // Closed and dropped before anything else looks at either: an
+ // attempt that failed must not be handed to `releaseWhenIdle` by
+ // the unmount cleanup, which exists to keep a *working* connection
+ // alive for a download still using it.
+ try { transport.close(); } catch { /* never opened */ }
+ transport = null;
+ transportRef.current = null;
+ if (cancelled) return;
+ // A refusal that names a state of *this browser* — a code to enter,
+ // a passphrase, a device to approve — is the same answer from every
+ // node, and the operator to act on is this one's. Trying the next
+ // node would only replace it with a less useful message.
+ if (e.reason && e.reason !== 'not_hosted') throw e;
+ }
+ }
+ if (!transport) throw (lastErr || new Error('no node served this group'));
session.pendingJoinCode = null;
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 32005b7..a21da19 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -283,6 +283,10 @@ const HANDSHAKE_REFUSALS = {
version_too_new: 'This node is running an older MeshBay than this page needs. '
+ 'Its operator has to update it.',
version_unreadable: 'The node could not read this page\'s protocol version.',
+ // Surfaced only when it was the *last* node the hub offered for the group —
+ // group-page.js moves on to the next one on this code rather than stopping.
+ not_hosted: 'No node the hub offered for this group is hosting it. Its '
+ + 'operator has to attach it on the node that holds its files.',
};
const JOIN_REFUSALS = {
diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py
index 1391722..4ead0d7 100644
--- a/packages/meshbay-hub/tests/test_node_ws_auth.py
+++ b/packages/meshbay-hub/tests/test_node_ws_auth.py
@@ -344,3 +344,123 @@ async def test_announce_with_valid_proof_succeeds_and_is_idempotent(client):
assert second.status_code == 201, second.text
assert second.json()["node_id"] == first.json()["node_id"], (
"re-announcing the same key must not create a second node record (M8)")
+
+
+# ── An empty claim is not a claim on everything ──────────────────────────────
+#
+# 2026-09-11, found on a live deployment. `_claimable` used to read
+# `set(claimed_groups or authorized)`, and the node omits `group_ids` entirely
+# when it hosts nothing — so "I host no groups" was read as "I host all of
+# yours". The node cannot serve any of them (no GEK, and its own handshake
+# refuses them), but `/v1/groups/{id}/nodes` lists nodes in registration order,
+# so whenever such a node won the reconnection race after a hub restart it
+# became `nodes[0]` and the group stopped opening for every member.
+
+
+@pytest.mark.asyncio
+async def test_ws_absent_claim_registers_no_groups(client):
+ """A node that declares nothing hosts nothing — it must not inherit the set."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "empty1")
+ node_id = await _announce_node(client, user)
+ for name in ("has-one", "has-two"):
+ r = await client.post(
+ "/v1/groups",
+ json={"name": name, "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+
+ resolved, groups = await _authorize_node_ws(_node_token(user), node_id, None)
+ assert resolved == node_id
+ assert groups == [], (
+ "a node hosting nothing was registered as a host for its owner's groups")
+
+
+@pytest.mark.asyncio
+async def test_ws_explicit_empty_claim_registers_no_groups(client):
+ """And the same when the node says so out loud, which it now does."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "empty2")
+ node_id = await _announce_node(client, user)
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "lonely", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+
+ resolved, groups = await _authorize_node_ws(_node_token(user), node_id, [])
+ assert resolved == node_id
+ assert groups == []
+
+
+@pytest.mark.asyncio
+async def test_empty_node_cannot_shadow_another_members_group(client):
+ """
+ The outage itself: two members, one group, and only one of them hosts it.
+ The other's node — running, configured with nothing — must not appear as a
+ source for that group, because it is the one clients would reach first.
+ """
+ from meshbay_hub.api.revocation import (
+ _authorize_node_ws, _node_groups, get_online_nodes_for_group)
+
+ host = await _make_user(client, "hoster")
+ guest = await _make_user(client, "guest")
+ host_node = await _announce_node(client, host)
+ guest_node = await _announce_node(client, guest)
+
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "shared", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {host['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ group_id = r.json()["group_id"]
+
+ r = await client.post(
+ f"/v1/groups/{group_id}/members/{'guest'}",
+ headers={"Authorization": f"Bearer {host['token']}"},
+ )
+ assert r.status_code == 201, r.text
+
+ # The guest's node registers first — the order that made this fatal.
+ _, guest_groups = await _authorize_node_ws(_node_token(guest), guest_node, None)
+ _, host_groups = await _authorize_node_ws(
+ _node_token(host), host_node, [group_id])
+ _node_groups[guest_node] = guest_groups
+ _node_groups[host_node] = host_groups
+ try:
+ assert get_online_nodes_for_group(group_id) == [host_node], (
+ "a member's empty node shadowed the node actually hosting the group")
+ finally:
+ _node_groups.pop(guest_node, None)
+ _node_groups.pop(host_node, None)
+
+
+@pytest.mark.asyncio
+async def test_update_groups_is_held_to_the_same_ceiling(client):
+ """
+ `update_groups` assigned the message's list verbatim, so the C2 ceiling held
+ at authentication could be stepped over one message later: a node had only
+ to reload to claim any group on the hub. It now goes through the same gate,
+ which is what this asserts — the socket loop itself needs a WebSocket the
+ ASGI harness has not got (see this module's docstring).
+ """
+ from meshbay_hub.api.revocation import _authorized_groups, _claimable
+
+ user = await _make_user(client, "reloader")
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "owned", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ own = r.json()["group_id"]
+
+ authorized = await _authorized_groups(user["user_id"])
+ assert _claimable([own, "someone-elses-group"], authorized) == [own]
+ assert _claimable([], authorized) == []
+ assert _claimable(None, authorized) == []
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index f2f4372..c506ff1 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -452,3 +452,57 @@ def test_the_index_is_never_reported_from_a_failed_decrypt(transport):
assert "catch" not in body, (
"_applyIndexMessage swallows its own failure instead of letting "
"_queueIndexMessage end the session")
+
+
+# ── One node refusing must not take a group down (2026-09-11) ────────────────
+#
+# `/v1/groups/{id}/nodes` returns every node registered for the group, in hub
+# registration order. GroupPage took `nodesData.nodes[0]` and stopped there, so
+# a node that could not serve the group — refusing the handshake with "Group
+# not hosted on this node" — made the group unopenable while the node that
+# *did* host it sat second in the same list.
+#
+# These strip comments first. The lesson this repeats otherwise is the CSP read
+# out of the comment above the meta tag, and the packaging unit whose test
+# matched the comment explaining why `User=` was absent: a source-level check
+# that can match prose is not a check.
+
+def _code_only(src: str) -> str:
+ """Source with // and /* */ comments removed. Crude, and enough here: no
+ string literal in these files carries a comment marker."""
+ src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
+ return re.sub(r"^\s*//.*$", "", src, flags=re.M)
+
+
+def test_the_group_page_tries_every_node_the_hub_offers(group_page):
+ code = _code_only(group_page)
+ assert "for (const n of nodesData.nodes)" in code, (
+ "the connect effect must walk the list, not index into it")
+ assert "nodesData.nodes[0]" not in code, (
+ "taking the head and stopping is the defect — one wrongly registered "
+ "node captured the whole group's traffic")
+
+
+def test_a_not_hosted_refusal_moves_on_to_the_next_node(group_page):
+ code = _code_only(group_page)
+ body = code[code.index("for (const n of nodesData.nodes)"):]
+ body = body[:body.index("if (!transport)")]
+ assert "not_hosted" in body, (
+ "without the code, a refusal this browser cannot act on is "
+ "indistinguishable from one it must stop for")
+ assert "throw e" in body, (
+ "a refusal naming a state of this browser — a pairing code, a "
+ "passphrase, a device — is the same from every node and must stop here")
+
+
+def test_the_last_refusal_is_what_the_reader_is_told(group_page):
+ code = _code_only(group_page)
+ assert "if (!transport) throw (lastErr" in code, (
+ "exhausting the list must report why, not fall through silently")
+
+
+def test_the_refusal_the_loop_keys_on_has_a_message(transport):
+ """A code the page routes on, with nothing to show, is a blank error."""
+ refusals = transport[transport.index("const HANDSHAKE_REFUSALS"):]
+ refusals = refusals[:refusals.index("};")]
+ assert "not_hosted:" in refusals
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
index 8873958..ef12bb4 100644
--- a/packages/meshbay-node/src/meshbay_node/hub_client.py
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -319,9 +319,15 @@ class HubClient:
"token": self._session.access_token,
"node_id": self._session.node_id,
}
+ # `if gids:` here, and the hub read a missing key as "claims
+ # every group this account belongs to" — so a node hosting
+ # nothing advertised itself for all of them and swallowed
+ # their traffic. The hub no longer widens an absent claim,
+ # and this says the empty set out loud rather than by
+ # omission: hosting nothing is a fact, not a missing field.
gids = group_ids() if callable(group_ids) else group_ids
- if gids:
- auth_msg["group_ids"] = gids
+ if gids is not None:
+ auth_msg["group_ids"] = list(gids)
await ws.send(json.dumps(auth_msg))
# Bounded: a hub that accepts the socket and then says nothing
# — which is what it does for a few seconds while restarting —
diff --git a/packages/meshbay-node/tests/test_hub_ws_group_claim.py b/packages/meshbay-node/tests/test_hub_ws_group_claim.py
new file mode 100644
index 0000000..49ac776
--- /dev/null
+++ b/packages/meshbay-node/tests/test_hub_ws_group_claim.py
@@ -0,0 +1,100 @@
+"""
+What the node claims to host, on the wire.
+
+2026-09-11: the node omitted `group_ids` from its WS auth frame whenever it
+hosted nothing (`if gids:`), and the hub read an absent claim as "every group
+this account belongs to". An unconfigured node was therefore registered as a
+source for other people's groups, could serve none of them, and — being listed
+first — took them down for every member.
+
+The hub no longer widens an absent claim. This pins the node's half: the empty
+set is stated, not left to be inferred from a missing field.
+"""
+
+import asyncio
+import json
+
+import pytest
+
+from meshbay_node.hub_client import HubClient, HubConfig, HubSession
+
+
+class _FakeWS:
+ """Just enough of a websockets connection for one auth round trip."""
+
+ def __init__(self, sent: list, authed: asyncio.Event):
+ self._sent = sent
+ self._authed = authed
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_):
+ return False
+
+ async def send(self, raw: str) -> None:
+ self._sent.append(json.loads(raw))
+
+ async def recv(self) -> str:
+ return json.dumps({"type": "auth_ok", "node_id": "node-1"})
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ # Registered; nothing more to drive. Hold here rather than closing, so
+ # the reconnect path does not run and muddy what was sent.
+ self._authed.set()
+ await asyncio.sleep(3600)
+ raise StopAsyncIteration
+
+
+async def _auth_frame(monkeypatch, group_ids) -> dict:
+ import websockets
+
+ sent: list = []
+ authed = asyncio.Event()
+ monkeypatch.setattr(
+ websockets, "connect", lambda *a, **kw: _FakeWS(sent, authed))
+
+ client = HubClient(HubConfig(hub_url="https://hub.test", username="u"),
+ keys=None)
+ client._session = HubSession(
+ hub_url="https://hub.test", username="u", user_id="user-1",
+ access_token="tok", refresh_token="ref", hub_pk_pem=b"", node_id="node-1")
+
+ task = asyncio.create_task(client.maintain_ws(group_ids=group_ids))
+ try:
+ await asyncio.wait_for(authed.wait(), timeout=5)
+ finally:
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ await client.close()
+
+ assert sent, "no auth frame was sent"
+ return sent[0]
+
+
+@pytest.mark.asyncio
+async def test_a_node_hosting_nothing_says_so(monkeypatch):
+ """The empty set on the wire, rather than a key the hub has to interpret."""
+ frame = await _auth_frame(monkeypatch, lambda: [])
+ assert "group_ids" in frame, (
+ "an absent claim is what the hub used to read as 'all of them'")
+ assert frame["group_ids"] == []
+
+
+@pytest.mark.asyncio
+async def test_a_node_declares_the_groups_it_hosts(monkeypatch):
+ frame = await _auth_frame(monkeypatch, lambda: ["g-1", "g-2"])
+ assert frame["group_ids"] == ["g-1", "g-2"]
+
+
+@pytest.mark.asyncio
+async def test_a_caller_with_nothing_to_declare_sends_no_key(monkeypatch):
+ """`None` is not the same as `[]`: it is "I am not answering that question"."""
+ frame = await _auth_frame(monkeypatch, None)
+ assert "group_ids" not in frame