From 4cce50f09a73739387d5058a6f8183ebac65ae2c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 12 Sep 2026 09:47:07 +0200 Subject: fix: an empty group claim is a claim on nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node that hosts no groups sends no `group_ids` on its hub socket, and the hub resolved the claim with `set(claimed_groups or authorized)` — so "I host nothing" arrived as "I host every group this account belongs to", other members' included. Such a node can serve none of them: it holds no GEK, and its own handshake refuses them with "Group not hosted on this node". `/v1/groups/{id}/nodes` answers in registration order and `_node_groups` is in-memory, so which node a client was sent to depended on who reconnected first after a hub restart. GroupPage took `nodes[0]` with no fallback. On 2026-09-11 a hub deploy at 20:14 reshuffled the registry, a second member's unconfigured node won the race, and a group stopped opening for everyone in it with its only real host online throughout. Any member could take one of their groups down, by accident, by leaving an empty node running. Four changes, because no one of them is sufficient: - the hub never widens an absent claim, and `update_groups` goes through the same ceiling as registration — it assigned its list verbatim, so the bound that makes C2 hold at authentication was one message wide - the node states the empty set rather than omitting the field - the refusal carries `not_hosted`, so a client can tell "try the next node" from "you, here, must do something first" - GroupPage walks the list instead of indexing into it The three lines involved date from 13, 20 and 23 August and each is defensible alone. The defect is in the seam, which is where the last two also were: a falsy empty collection must never mean "unspecified". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT --- .../src/meshbay_hub/static/group-page.js | 71 ++++++++++++++++------ .../src/meshbay_hub/static/transport.js | 4 ++ 2 files changed, 56 insertions(+), 19 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static') 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 = { -- cgit v1.2.3