From 1e8eae1f3de765fcd8163716de6e5fe5aab5a034 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 09:33:28 +0200 Subject: 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 --- .../meshbay-node/src/meshbay_node/hub_client.py | 48 ++++++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/hub_client.py') 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: -- cgit v1.2.3