aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/hub_client.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
commit1e8eae1f3de765fcd8163716de6e5fe5aab5a034 (patch)
treedfdb004229c8b0f57ea1a64a726ed2752c3d18e5 /packages/meshbay-node/src/meshbay_node/hub_client.py
parentadda9613fad48a68239e2dbd93321818a835c286 (diff)
downloadmeshbay-1e8eae1f3de765fcd8163716de6e5fe5aab5a034.tar.gz
feat(hub): one account, several linked nodes
An account links up to MAX_NODES_PER_ACCOUNT node keys (node_keys table) instead of the one users.pk_node_ed25519; the migration carries each account's key over and the downgrade keeps the last one linked. /v1/nodes/auth accepts any linked key, and its token names the key that won it (nk). PUT /me/node_key adds a key, idempotently, 409 past the limit; DELETE /me/node_key names the key: a browser session any of its own, a node only its own. Unlinking a key closes that node's socket and refuses its tokens at once, on REST and on the socket; the account's other nodes are untouched. /pubkeys no longer gives a node key to other accounts, and gives the caller its last linked one, which installed desktop apps read. The node reports waiting_for_node_key while its key is unlinked and signs in again as soon as the hub says so, so a key linked again is back within seconds; unlinking itself signs in first to hold a token naming its key. Profile -> Linked nodes lists each node (connected, last seen, IP, linked since, This machine in the desktop app) with Copy and Unlink, and adds a key by hand. The Node page banner, Create group and the desktop app add this machine's key instead of replacing another's. Messages that pointed at Settings -> Link Node point at Profile -> Linked nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/hub_client.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py48
1 files changed, 40 insertions, 8 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
index 54f87f0..020903d 100644
--- a/packages/meshbay-node/src/meshbay_node/hub_client.py
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -230,12 +230,21 @@ class HubClient:
return r.json().get("email", "")
async def unlink_node_key(self) -> None:
- """Clear pk_node_ed25519 on the hub (best-effort before reset)."""
+ """Unlink this node's key from its account; the account's other nodes keep theirs."""
if self._session is None:
return
- await self.ensure_fresh_token()
- r = await self._http.delete("/v1/users/me/node_key",
- headers=self._session.auth_headers)
+ # A fresh token, not merely an unexpired one: the hub lets a node unlink
+ # the key its token names, and a token issued before that claim existed
+ # names none. A key already unlinked has nothing left to let go of.
+ try:
+ await self.login()
+ except httpx.HTTPStatusError as e:
+ if e.response.status_code == 401 and "No node key" in e.response.text:
+ return
+ raise
+ r = await self._http.request("DELETE", "/v1/users/me/node_key",
+ json={"pk_node_ed25519": self._keys.pk_ed25519_b64},
+ headers=self._session.auth_headers)
r.raise_for_status()
# ── User pubkey lookup ────────────────────────────────────────────────────
@@ -336,6 +345,7 @@ class HubClient:
group_ids: list[str] | None = None, # static list or callable returning one
on_connected: Any = None,
on_blocklist: Any = None,
+ on_unlinked: Any = None,
) -> None:
"""
Maintain a persistent WebSocket connection to the hub.
@@ -385,12 +395,18 @@ class HubClient:
auth_resp = json.loads(
await asyncio.wait_for(ws.recv(), timeout=15))
if auth_resp.get("type") != "auth_ok":
- # Not fatal: the token may simply have expired while we
- # were disconnected. Refresh on the next pass rather than
- # ending the task, which used to strand the node for good.
+ # Not fatal: the token may have expired while we were
+ # disconnected, or this node's key was unlinked from the
+ # account (which ends its tokens at once). Sign in again
+ # rather than wait for the token to near its expiry: a key
+ # linked again is then back within seconds, and one still
+ # unlinked says so through `on_unlinked`.
log.warning("WS auth refused: %s — retrying in 5s", auth_resp)
await asyncio.sleep(5)
- await self.ensure_fresh_token()
+ if "no longer linked" in str(auth_resp.get("detail", "")):
+ await self._sign_in_again(on_unlinked)
+ else:
+ await self.ensure_fresh_token()
continue
self._ws = ws
@@ -447,6 +463,22 @@ class HubClient:
finally:
self._ws = None
+ async def _sign_in_again(self, on_unlinked: Any) -> None:
+ """Sign in afresh; tell `on_unlinked` when the hub no longer knows this key."""
+ try:
+ await self.login()
+ except httpx.HTTPStatusError as e:
+ if e.response.status_code == 401 and "No node key" in e.response.text:
+ log.warning("This node's key is no longer linked to %r — waiting "
+ "for it to be linked again", self._config.username)
+ if on_unlinked:
+ on_unlinked()
+ # Ten seconds a round in all: the hub allows 10 node sign-ins a
+ # minute, and a node waiting for its link must stay under it.
+ await asyncio.sleep(5)
+ return
+ raise
+
async def _answer_offer(self, ws, on_webrtc_offer, msg: dict) -> None:
"""Negotiate one WebRTC offer and return the answer, off the read loop."""
try: