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/tests/test_node_key_unlinked.py | 180 +++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/meshbay-node/tests/test_node_key_unlinked.py (limited to 'packages/meshbay-node/tests/test_node_key_unlinked.py') diff --git a/packages/meshbay-node/tests/test_node_key_unlinked.py b/packages/meshbay-node/tests/test_node_key_unlinked.py new file mode 100644 index 0000000..85674e1 --- /dev/null +++ b/packages/meshbay-node/tests/test_node_key_unlinked.py @@ -0,0 +1,180 @@ +""" +A node whose key was unlinked from its account notices, and comes back when it is linked again. + +The hub ends an unlinked node's tokens at once and refuses its socket with +"Node key no longer linked". The node then signs in afresh instead of waiting +for its token to near expiry — so a key linked again is back within seconds — +and while the hub answers "No node key", it reports `waiting_for_node_key` +rather than `running`. +""" + +import asyncio +import json + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from meshbay_node import hub_client as hc +from meshbay_node.hub_client import HubClient, HubConfig +from meshbay_node.keystore import NodeKeys + + +def _client(tmp_path): + keys = NodeKeys(sk_ed25519=Ed25519PrivateKey.generate(), + sk_x25519=X25519PrivateKey.generate()) + return HubClient(HubConfig(hub_url="http://fake-hub", username="testuser", + cache_dir=tmp_path), keys) + + +def _status_error(status, detail): + req = httpx.Request("POST", "http://fake-hub/v1/nodes/auth") + resp = httpx.Response(status, json={"detail": detail}, request=req) + return httpx.HTTPStatusError(detail, request=req, response=resp) + + +@pytest.fixture(autouse=True) +def _no_wait(monkeypatch): + async def _instant(_s): + return None + monkeypatch.setattr(hc.asyncio, "sleep", _instant) + + +@pytest.mark.asyncio +async def test_an_unlinked_key_is_reported(tmp_path, monkeypatch): + hub = _client(tmp_path) + + async def login(): + raise _status_error(401, "No node key registered for this node — link it") + monkeypatch.setattr(hub, "login", login) + + told = [] + await hub._sign_in_again(lambda: told.append(True)) + assert told == [True] + + +@pytest.mark.asyncio +async def test_a_key_linked_again_signs_in_without_reporting(tmp_path, monkeypatch): + hub = _client(tmp_path) + calls = [] + + async def login(): + calls.append(True) + monkeypatch.setattr(hub, "login", login) + + told = [] + await hub._sign_in_again(lambda: told.append(True)) + assert calls == [True] + assert told == [] + + +@pytest.mark.asyncio +async def test_other_failures_are_not_mistaken_for_an_unlink(tmp_path, monkeypatch): + hub = _client(tmp_path) + + async def login(): + raise _status_error(429, "Too many requests") + monkeypatch.setattr(hub, "login", login) + + told = [] + with pytest.raises(httpx.HTTPStatusError): + await hub._sign_in_again(lambda: told.append(True)) + assert told == [] + + +class _RefusingSocket: + """A hub that refuses the node's socket once, with `detail`.""" + + def __init__(self, detail, stop): + self.detail, self.stop, self.sent = detail, stop, [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def send(self, data): + self.sent.append(data) + + async def recv(self): + self.stop.set() + return json.dumps({"type": "error", "detail": self.detail}) + + +@pytest.mark.parametrize("detail, signs_in_again", [ + ("Node key no longer linked to this account", True), + ("Node already connected", False), +]) +@pytest.mark.asyncio +async def test_only_an_unlink_refusal_signs_in_again(tmp_path, monkeypatch, detail, + signs_in_again): + """Any other refusal keeps the old pace, so `/auth`'s rate limit is not spent on it.""" + import types + + import websockets + + hub = _client(tmp_path) + hub._session = types.SimpleNamespace(hub_url="http://fake-hub", access_token="t", + node_id="n1") + stop = asyncio.Event() + monkeypatch.setattr(websockets, "connect", + lambda *a, **k: _RefusingSocket(detail, stop)) + signed_in, refreshed = [], [] + + async def sign_in_again(_cb): + signed_in.append(True) + raise asyncio.CancelledError + + async def ensure_fresh(): + refreshed.append(True) + raise asyncio.CancelledError + monkeypatch.setattr(hub, "_sign_in_again", sign_in_again) + monkeypatch.setattr(hub, "ensure_fresh_token", ensure_fresh) + + with pytest.raises(asyncio.CancelledError): + await hub.maintain_ws(group_ids=[]) + assert bool(signed_in) is signs_in_again + assert bool(refreshed) is not signs_in_again + + +@pytest.mark.asyncio +async def test_unlinking_itself_signs_in_first_and_names_its_key(tmp_path, monkeypatch): + """A token from before the hub named keys cannot unlink; a fresh one names this node's.""" + import types + + hub = _client(tmp_path) + hub._session = types.SimpleNamespace(auth_headers={"Authorization": "Bearer fresh"}) + order, sent = [], {} + + async def login(): + order.append("login") + monkeypatch.setattr(hub, "login", login) + + async def request(method, url, json=None, headers=None): + order.append(method) + sent.update(json or {}) + return httpx.Response(200, json={}, request=httpx.Request(method, "http://h" + url)) + monkeypatch.setattr(hub._http, "request", request) + + await hub.unlink_node_key() + assert order == ["login", "DELETE"] + assert sent == {"pk_node_ed25519": hub._keys.pk_ed25519_b64} + + +@pytest.mark.asyncio +async def test_unlinking_a_key_already_unlinked_is_not_an_error(tmp_path, monkeypatch): + import types + + hub = _client(tmp_path) + hub._session = types.SimpleNamespace(auth_headers={}) + + async def login(): + raise _status_error(401, "No node key registered for this node — link it") + monkeypatch.setattr(hub, "login", login) + + async def request(*a, **k): + raise AssertionError("nothing to unlink, nothing should be sent") + monkeypatch.setattr(hub._http, "request", request) + + await hub.unlink_node_key() -- cgit v1.2.3