feat(pairing): default seed relay to the nostrclient endpoint + validate relays
Some checks failed
ci.yml / feat(pairing): default seed relay to the nostrclient endpoint + validate relays (pull_request) Failing after 0s

Two robustness fixes for on-machine pairing (bitspire-#70), after a QR scan
silently corrupted a seed's relay (ws://→As://) and crash-looped a machine on
an unreachable relay:

- Default relays: when the operator omits `relays`, derive the seed's relay from
  THIS lnbits' own nostrclient proxy endpoint — `<ws(s)>://<host>/nostrclient/api/v1/relay`,
  built from `lnbits_baseurl` (default_relay_endpoint). Operators configure
  upstream relays once in the nostrclient extension (public_ws must be on) and
  every seed points at one stable, operator-independent URL. `relays` is now
  optional on PairMachineData + the /pair endpoint.
- Validate every relay (+ bunker_relay) is a `ws://`/`wss://` URL AND reject
  loopback hosts (localhost/127.0.0.1/::1/0.0.0.0) — a seed is redeemed by a
  REMOTE machine, so a localhost relay is exactly the unreachable case. Catches
  both the ws://→As:// corruption and the localhost /pair gotcha at mint time.

Consumer side (bitspire): parseSpireSeed rejects non-ws relays, and the wizard
gained a "test relay" reachability button before committing.

223 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-02 00:00:13 +02:00
commit c3791ed6c8
5 changed files with 143 additions and 27 deletions

View file

@ -113,11 +113,12 @@ def test_pair_persists_hex_npub_and_returns_seed(monkeypatch):
assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1")
def test_pair_empty_relays_rejected(monkeypatch):
def test_pair_empty_relays_accepted(monkeypatch):
# Empty/omitted relays are no longer a 400 — pair_spire defaults them to the
# nostrclient endpoint (and validates), so the endpoint passes them through.
_wire(monkeypatch)
with pytest.raises(HTTPException) as ei:
_call([])
assert ei.value.status_code == 400
result = _call([])
assert result.seed_url == "spire-seed:v1:abc"
def test_pair_failure_maps_to_bad_gateway(monkeypatch):

View file

@ -28,11 +28,15 @@ from ..pairing import (
SPIRE_POLICY_RULES,
PairingError,
build_seed_url,
default_relay_endpoint,
pair_spire,
revoke_spire,
spire_key_name,
)
_BASEURL = "https://lnbits.example.com/"
_NOSTRCLIENT_ENDPOINT = "wss://lnbits.example.com/nostrclient/api/v1/relay"
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX)
@ -48,9 +52,12 @@ def _set_transport_pubkey():
# pair_spire reads this lnbits' nostr-transport server pubkey to embed
# lnbits_npub in the seed (bitspire#70). Set it for every test; restore after.
prev = settings.nostr_transport_public_key
prev_base = settings.lnbits_baseurl
settings.nostr_transport_public_key = _LNBITS_HEX
settings.lnbits_baseurl = _BASEURL
yield
settings.nostr_transport_public_key = prev
settings.lnbits_baseurl = prev_base
@pytest.fixture(autouse=True)
@ -263,7 +270,7 @@ def test_bunker_relay_defaults_to_spire_event_relay():
assert "127.0.0.1" not in result.bunker_url
def test_missing_relay_or_passphrase_raises():
def test_missing_passphrase_raises():
with pytest.raises(PairingError, match="PASSPHRASE"):
asyncio.run(
pair_spire(
@ -274,13 +281,63 @@ def test_missing_relay_or_passphrase_raises():
keystore_passphrase="",
)
)
with pytest.raises(PairingError, match="relay is required"):
def test_relays_default_to_nostrclient_endpoint():
# No relays given → derived from lnbits_baseurl → nostrclient proxy endpoint.
result = asyncio.run(
pair_spire(
_machine(),
admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret
keystore_passphrase=_PASSPHRASE,
)
)
assert _decode_seed(result.seed_url)["relays"] == [_NOSTRCLIENT_ENDPOINT]
def test_default_relay_endpoint_scheme_map():
prev = settings.lnbits_baseurl
try:
settings.lnbits_baseurl = "http://192.168.0.32:5001"
assert default_relay_endpoint() == "ws://192.168.0.32:5001/nostrclient/api/v1/relay"
settings.lnbits_baseurl = "https://lnbits.example.com/"
assert default_relay_endpoint() == _NOSTRCLIENT_ENDPOINT
settings.lnbits_baseurl = ""
assert default_relay_endpoint() is None
finally:
settings.lnbits_baseurl = prev
def test_no_relays_and_no_baseurl_raises():
prev = settings.lnbits_baseurl
try:
settings.lnbits_baseurl = ""
with pytest.raises(PairingError, match="cannot derive"):
asyncio.run(
pair_spire(
_machine(), admin_client=FakeBunker(), keystore_passphrase=_PASSPHRASE
)
)
finally:
settings.lnbits_baseurl = prev
@pytest.mark.parametrize(
"bad_relay, match",
[
("As://192.168.0.32:5001/x", "ws://"), # QR misread ws://→As://
("http://192.168.0.32:5001/x", "ws://"),
("ws://localhost:5001/nostrrelay/test", "localhost"),
("ws://127.0.0.1:5001/nostrrelay/test", "localhost"),
],
)
def test_rejects_bad_relay(bad_relay, match):
with pytest.raises(PairingError, match=match):
asyncio.run(
pair_spire(
_machine(),
relays=[],
relays=[bad_relay],
admin_client=FakeBunker(),
bunker_relay=_BUNKER_RELAY,
keystore_passphrase=_PASSPHRASE,
)
)