From b93e3fb698acf7a1bca9d6c40001be2b7626ef4a Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 1 Jul 2026 21:16:10 +0200 Subject: [PATCH 1/7] test(pair-endpoint): update stale pair_spire double + mock get_super_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pairing endpoint tests' fake_pair predated the bunker_relay parameter (views_api passes bunker_relay=data.bunker_relay), so it raised TypeError; and api_pair_machine later grew a get_super_config() read for the post-pair fee publish that the doubles never mocked, hitting a DB with no spirekeeper.super_config table. Both were pre-existing failures on main (masked one behind the other). Add bunker_relay to the fake_pair signature and mock get_super_config → None (the happy path doesn't exercise fee publishing). Suite green. Co-Authored-By: Claude Opus 4.8 --- tests/test_pair_endpoint.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py index 0d50d95..745bbfe 100644 --- a/tests/test_pair_endpoint.py +++ b/tests/test_pair_endpoint.py @@ -66,7 +66,9 @@ def _wire(monkeypatch, *, pair="ok"): async def fake_owned(machine_id, user_id): return _machine() - async def fake_pair(machine, *, relays, admin_client, duration_hours=None): + async def fake_pair( + machine, *, relays, admin_client, bunker_relay=None, duration_hours=None + ): if pair == "error": raise PairingError("boom") return _result() @@ -80,11 +82,18 @@ def _wire(monkeypatch, *, pair="ok"): state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name) return _machine(npub=machine_npub) + # After pairing, the endpoint reads super_config to publish fee config + # (soft-fail tail). None short-circuits it — the happy-path assertions + # don't exercise fee publishing, and it keeps the test off the DB. + async def fake_super_config(): + return None + monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned) monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin) monkeypatch.setattr(views_api, "pair_spire", fake_pair) monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_collision) monkeypatch.setattr(views_api, "set_machine_pairing", fake_persist) + monkeypatch.setattr(views_api, "get_super_config", fake_super_config) return state From 9dc4d09973e9b6e1fe874d78aaeeaf03f2ccb940 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 1 Jul 2026 21:16:10 +0200 Subject: [PATCH 2/7] feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mint the new-shape seed the bitspire consumer now expects: the pubkey rides once as spire_npub (consumer derives the hex + reconstructs bunker_url from bunker_secret + bunker_relay|relays[0]), and lnbits_npub is embedded so a paired machine reaches this lnbits' nostr-transport with nothing else provisioned. - build_seed_url emits {spire_npub, lnbits_npub, bunker_secret, relays} and bunker_relay only when it differs from relays[0] (omitted in the common case). Drops spire_pubkey + the full bunker_url from the payload. - pair_spire reads settings.nostr_transport_public_key, hex_to_npub's it, and raises PairingError when it's empty (transport not running → can't mint a self-sufficient seed). bunker_url is still returned in PairResult for operator display / audit; only the seed stops embedding it. Consumer side: bitspire packages/nostr-client/src/seed.ts + the #70 machine wiring. Kept as v: 1 (redefined in place; no shipped seed to preserve). Co-Authored-By: Claude Opus 4.8 --- pairing.py | 57 ++++++++++++++++++++++++++++------- tests/test_pairing.py | 69 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 105 insertions(+), 21 deletions(-) diff --git a/pairing.py b/pairing.py index c2d923f..83933e8 100644 --- a/pairing.py +++ b/pairing.py @@ -23,15 +23,21 @@ We deliberately do NOT run the connect/eager-bind step here: the spire is the NIP-46 client, so the binding must happen spire-side with the spire's own client keypair. spirekeeper only mints + packages. -Seed URL wire format (contract shared with bitspire#52): +Seed URL wire format (contract shared with bitspire#52, slimmed in bitspire#70): spire-seed:v1: json = { "v": 1, - "spire_npub": "npub1…", # the bunker-minted spire identity - "spire_pubkey": "<64-hex>", # same key, hex (consumer convenience) - "bunker_url": "bunker://?relay=&secret=", - "relays": ["wss://…"], # relays for the spire's own events + "spire_npub": "npub1…", # the bunker-minted spire identity + "lnbits_npub": "npub1…", # this lnbits' nostr-transport server id + "bunker_secret": "", # one-shot NIP-46 connect token + "relays": ["wss://…"], # relays for the spire's own events + "bunker_relay": "wss://…", # OPTIONAL — omitted when == relays[0] } + +The pubkey is carried ONCE, as an npub: the consumer derives spire_pubkey (hex) +and reconstructs the bunker:// URL from spire_npub + bunker_relay|relays[0] + +bunker_secret. `lnbits_npub` lets a paired machine reach this lnbits' transport +with nothing else provisioned. See bitspire packages/nostr-client/src/seed.ts. """ from __future__ import annotations @@ -48,6 +54,7 @@ from lnbits.core.services.nsec_bunker import ( ) from lnbits.core.signers.remote_bunker import ensure_policy from lnbits.settings import settings +from lnbits.utils.nostr import hex_to_npub from pydantic import BaseModel from .models import Machine @@ -136,15 +143,27 @@ def _recover_token(tokens: list[dict], client_name: str) -> str: def build_seed_url( - *, spire_npub: str, spire_pubkey_hex: str, bunker_url: str, relays: list[str] + *, + spire_npub: str, + lnbits_npub: str, + bunker_secret: str, + relays: list[str], + bunker_relay: str | None = None, ) -> str: - payload = { + """Build the slim seed URL (bitspire#70). The pubkey rides once as + `spire_npub`; the consumer derives the hex + reconstructs `bunker_url` from + `bunker_secret` + `bunker_relay` (or `relays[0]`). `bunker_relay` is emitted + only when it differs from `relays[0]`, keeping the common case one field + lighter.""" + payload: dict = { "v": 1, "spire_npub": spire_npub, - "spire_pubkey": spire_pubkey_hex, - "bunker_url": bunker_url, + "lnbits_npub": lnbits_npub, + "bunker_secret": bunker_secret, "relays": relays, } + if bunker_relay and relays and bunker_relay != relays[0]: + payload["bunker_relay"] = bunker_relay blob = ( base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()) .decode() @@ -233,15 +252,31 @@ async def pair_spire( token = _recover_token(tokens, client_name) _, _, secret = token.partition("#") + # The spire needs THIS lnbits' nostr-transport server identity to reach the + # backend from the seed alone (bitspire#70). The transport sets + # `settings.nostr_transport_public_key` at startup; if it's empty the + # transport isn't running, so we can't mint a self-sufficient seed. + lnbits_pubkey_hex = settings.nostr_transport_public_key + if not lnbits_pubkey_hex: + raise PairingError( + "LNbits nostr transport has no server pubkey " + "(settings.nostr_transport_public_key is empty) — is the transport " + "running? Cannot mint a self-sufficient seed." + ) + lnbits_npub = hex_to_npub(lnbits_pubkey_hex) + + # bunker_url is still returned in PairResult (operator display / audit); the + # seed itself no longer embeds it — the consumer reconstructs it. bunker_url = ( f"bunker://{spire_pubkey_hex}?relay={quote(relay, safe='')}" f"&secret={quote(secret, safe='')}" ) seed_url = build_seed_url( spire_npub=spire_npub, - spire_pubkey_hex=spire_pubkey_hex, - bunker_url=bunker_url, + lnbits_npub=lnbits_npub, + bunker_secret=secret, relays=relays, + bunker_relay=relay, ) return PairResult( spire_npub=spire_npub, diff --git a/tests/test_pairing.py b/tests/test_pairing.py index e3d2999..1062afd 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -17,6 +17,7 @@ from datetime import datetime, timezone import pytest from lnbits.core.services.nsec_bunker import NsecBunkerError +from lnbits.settings import settings from lnbits.utils.nostr import hex_to_npub from ..models import Machine @@ -35,11 +36,23 @@ from ..pairing import ( _NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) _SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" _SPIRE_NPUB = hex_to_npub(_SPIRE_HEX) +_LNBITS_HEX = "b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf" +_LNBITS_NPUB = hex_to_npub(_LNBITS_HEX) _RELAYS = ["wss://lnbits.demo.aiolabs.dev/nostrrelay/demo"] _BUNKER_RELAY = "wss://bunker.internal/relay" _PASSPHRASE = "keystore-pass" # pragma: allowlist secret +@pytest.fixture(autouse=True) +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 + settings.nostr_transport_public_key = _LNBITS_HEX + yield + settings.nostr_transport_public_key = prev + + @pytest.fixture(autouse=True) def _clear_policy_cache(): # lnbits' ensure_policy caches resolved policy ids on @@ -157,18 +170,28 @@ def test_bunker_url_carries_pubkey_relay_secret(): assert "secret=topsecret" in result.bunker_url +def _decode_seed(seed_url: str) -> dict: + assert seed_url.startswith(SEED_URL_SCHEME) + blob = seed_url[len(SEED_URL_SCHEME) :] + return json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) + + def test_seed_url_decodes_to_contract(): + # _pair passes an explicit bunker_relay distinct from relays[0], so it's + # carried; the pubkey rides once as spire_npub, lnbits_npub is embedded, + # and neither spire_pubkey nor bunker_url appears (bitspire#70). result = _pair(FakeBunker(token_secret="zzz")) # pragma: allowlist secret - assert result.seed_url.startswith(SEED_URL_SCHEME) - blob = result.seed_url[len(SEED_URL_SCHEME) :] - payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) + payload = _decode_seed(result.seed_url) assert payload == { "v": 1, "spire_npub": _SPIRE_NPUB, - "spire_pubkey": _SPIRE_HEX, - "bunker_url": result.bunker_url, + "lnbits_npub": _LNBITS_NPUB, + "bunker_secret": "zzz", "relays": _RELAYS, + "bunker_relay": _BUNKER_RELAY, } + assert "spire_pubkey" not in payload + assert "bunker_url" not in payload def test_fresh_policy_adds_kindless_nip44_rules(): @@ -266,14 +289,40 @@ def test_missing_relay_or_passphrase_raises(): def test_build_seed_url_roundtrip(): url = build_seed_url( spire_npub=_SPIRE_NPUB, - spire_pubkey_hex=_SPIRE_HEX, - bunker_url="bunker://x?relay=r&secret=s", + lnbits_npub=_LNBITS_NPUB, + bunker_secret="s", # pragma: allowlist secret relays=_RELAYS, + bunker_relay=_BUNKER_RELAY, ) - blob = url[len(SEED_URL_SCHEME) :] - payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) - assert payload["spire_pubkey"] == _SPIRE_HEX + payload = _decode_seed(url) + assert payload["spire_npub"] == _SPIRE_NPUB + assert payload["lnbits_npub"] == _LNBITS_NPUB + assert payload["bunker_secret"] == "s" assert payload["relays"] == _RELAYS + assert payload["bunker_relay"] == _BUNKER_RELAY + + +def test_build_seed_url_omits_default_bunker_relay(): + # bunker_relay omitted when it equals relays[0] (or is None) — the consumer + # defaults it to relays[0], so carrying it would be redundant bytes. + for same in (_RELAYS[0], None): + payload = _decode_seed( + build_seed_url( + spire_npub=_SPIRE_NPUB, + lnbits_npub=_LNBITS_NPUB, + bunker_secret="s", # pragma: allowlist secret + relays=_RELAYS, + bunker_relay=same, + ) + ) + assert "bunker_relay" not in payload + + +def test_pair_missing_transport_pubkey_raises(): + # No transport server pubkey → can't mint a self-sufficient seed. + settings.nostr_transport_public_key = "" + with pytest.raises(PairingError, match="transport"): + _pair(FakeBunker()) def test_pair_threads_duration_hours(): From c3791ed6c88a93f6139dc8bb9e8cdc1db5287800 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 00:00:13 +0200 Subject: [PATCH 3/7] feat(pairing): default seed relay to the nostrclient endpoint + validate relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — `:///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 --- models.py | 8 ++-- pairing.py | 84 ++++++++++++++++++++++++++++++------- tests/test_pair_endpoint.py | 9 ++-- tests/test_pairing.py | 65 ++++++++++++++++++++++++++-- views_api.py | 4 +- 5 files changed, 143 insertions(+), 27 deletions(-) diff --git a/models.py b/models.py index 99ee552..34db4b6 100644 --- a/models.py +++ b/models.py @@ -94,10 +94,12 @@ class PairMachineData(BaseModel): the relay lnbits uses to reach the bunker differs from the one the spire must reach — e.g. an internal docker hostname (`ws://lnbits:5001/…`) vs a LAN/public URL (`ws://192.168.0.32:5001/…`), or any split-relay deploy. - `duration_hours` optionally time-bounds the spire's connect token - (None = non-expiring).""" + `relays` is optional: when omitted it defaults to this lnbits' nostrclient + proxy endpoint (derived from `lnbits_baseurl`), so the operator needn't + supply one (bitspire#70). `duration_hours` optionally time-bounds the + spire's connect token (None = non-expiring).""" - relays: list[str] + relays: list[str] | None = None bunker_relay: str | None = None duration_hours: int | None = None diff --git a/pairing.py b/pairing.py index 83933e8..a751e1d 100644 --- a/pairing.py +++ b/pairing.py @@ -44,7 +44,8 @@ from __future__ import annotations import base64 import json -from urllib.parse import quote +import re +from urllib.parse import quote, urlparse from lnbits.core.services.nsec_bunker import ( NsecBunkerAdminClient, @@ -172,10 +173,49 @@ def build_seed_url( return SEED_URL_SCHEME + blob +_WS_SCHEME_RE = re.compile(r"^wss?://", re.IGNORECASE) +_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"} + +# The nostrclient extension serves a public relay-multiplexer websocket at this +# path (see nostrclient/views_api.py `ws_relay`, gated by config.public_ws). +NOSTRCLIENT_RELAY_PATH = "/nostrclient/api/v1/relay" + + +def default_relay_endpoint() -> str | None: + """Derive the machine-facing relay from THIS lnbits' own nostrclient proxy + endpoint, so operators configure upstream relays once in the nostrclient + extension and every seed points at one stable URL (bitspire#70). Built from + `settings.lnbits_baseurl` (http→ws, https→wss). Returns None when the base + URL is unset. Requires the nostrclient extension's `public_ws` to be on.""" + base = (settings.lnbits_baseurl or "").strip().rstrip("/") + if not base: + return None + if base.startswith("https://"): + base = "wss://" + base[len("https://") :] + elif base.startswith("http://"): + base = "ws://" + base[len("http://") :] + return base + NOSTRCLIENT_RELAY_PATH + + +def _validate_relay(url: str, field: str) -> None: + """A seed relay must be a `ws(s)://` URL reachable by a REMOTE machine. Reject + loopback hosts: a localhost relay in a seed is the exact unreachable case + that silently crash-loops pairing (bitspire#70).""" + if not isinstance(url, str) or not _WS_SCHEME_RE.match(url): + raise PairingError(f"{field} must be a ws:// or wss:// URL (got {url!r})") + host = (urlparse(url).hostname or "").lower() + if host in _LOCAL_HOSTS or host.endswith(".localhost"): + raise PairingError( + f"{field} points at localhost ({url!r}). A pairing seed is redeemed by " + "a REMOTE machine — set lnbits_baseurl (and any explicit relay) to a " + "machine-reachable address." + ) + + async def pair_spire( machine: Machine, *, - relays: list[str], + relays: list[str] | None = None, admin_client: NsecBunkerAdminClient, bunker_relay: str | None = None, keystore_passphrase: str | None = None, @@ -199,10 +239,14 @@ async def pair_spire( with a fake client. `relays` are the relays the spire uses for its *own* events - (kind-21000/30078) — typically the operator's public nostrrelay; supplied by - the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the - spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase` - defaults to the lnbits bunker setting. Both injectable for tests. + (kind-21000/30078). When omitted, they default to this lnbits' nostrclient + proxy endpoint (`default_relay_endpoint`, derived from `lnbits_baseurl`) so + operators configure upstream relays once in the extension and every seed + points at one stable URL. Every relay (and `bunker_relay`) is validated as a + remote-reachable `ws(s)://` URL — a loopback host is rejected (bitspire#70). + `bunker_relay` (the relay baked into `bunker_url`, where the spire reaches + the bunker) defaults to `relays[0]`; `keystore_passphrase` defaults to the + lnbits bunker setting. All injectable for tests. Raises PairingError on any bunker failure; no state is persisted here (the API layer persists on success). @@ -217,15 +261,27 @@ async def pair_spire( "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " "cannot mint a spire key" ) + # Default the spire's event relay(s) to this lnbits' nostrclient proxy + # endpoint when the caller gives none. if not relays: - raise PairingError("at least one relay is required for the seed URL") - # The relay baked into `bunker_url` is where the *spire* (the remote ATM) - # reaches the bunker, so it must be a machine-reachable public URL — NOT - # `settings.lnbits_nsec_bunker_url`, which is how the co-located lnbits - # reaches the bunker (typically ws://127.0.0.1, unreachable from the ATM — - # the localhost-relay /pair gotcha bitspire flagged). Default to the spire's - # own event relay (the bunker lives on the same operator relay the spire - # publishes to); an explicit `bunker_relay` overrides for split-relay deploys. + endpoint = default_relay_endpoint() + if not endpoint: + raise PairingError( + "no relays given and lnbits_baseurl is unset — cannot derive the " + "nostrclient relay endpoint for the seed" + ) + relays = [endpoint] + + # Validate every relay is a remote-reachable ws(s):// URL (rejects loopback + + # the ws://→As:// class of corruption). The bunker relay is validated too: + # it's baked into `bunker_url` where the *spire* (remote ATM) reaches the + # bunker, so it must be machine-reachable — NOT `settings.lnbits_nsec_bunker_url` + # (the co-located ws://127.0.0.1 path). Default it to the spire's own event + # relay; an explicit `bunker_relay` overrides for split-relay deploys. + for i, r in enumerate(relays): + _validate_relay(r, f"relays[{i}]") + if bunker_relay: + _validate_relay(bunker_relay, "bunker_relay") relay = bunker_relay if bunker_relay else relays[0] key_name = spire_key_name(machine.id) diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py index 745bbfe..775fd4f 100644 --- a/tests/test_pair_endpoint.py +++ b/tests/test_pair_endpoint.py @@ -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): diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 1062afd..15334fa 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -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, ) ) diff --git a/views_api.py b/views_api.py index 35d35b2..ebaf53b 100644 --- a/views_api.py +++ b/views_api.py @@ -315,8 +315,8 @@ async def api_pair_machine( `duration_hours` (optional) time-bounds the token; revoke via the sibling `POST .../revoke` endpoint.""" machine = await _machine_owned_by(machine_id, user.id) - if not data.relays: - raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required") + # relays may be omitted — pair_spire defaults to this lnbits' nostrclient + # proxy endpoint and validates reachability (raises PairingError → 502). try: async with NsecBunkerAdminClient.from_settings() as client: From 4e36b985347e9af09926e87b627b2116bae1012f Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 00:16:46 +0200 Subject: [PATCH 4/7] feat(pairing-ui): pre-fill the pair dialog relay with the default endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend now defaults an omitted relay to the nostrclient proxy endpoint, but the pair dialog still showed an empty, client-side-required field — so the operator saw "no default". Wire it through: - GET /api/v1/dca/default-relay returns default_relay_endpoint() (the derived ws(s):///nostrclient/api/v1/relay). - openPairDialog pre-fills the relay textarea with it; the operator can override or clear it (blank → same server-side default). Drops the client-side "at least one relay is required" guard. - Hint updated to explain the default. Co-Authored-By: Claude Opus 4.8 --- static/js/index.js | 23 +++++++++++++++-------- templates/spirekeeper/index.html | 2 +- tests/test_pair_endpoint.py | 12 ++++++++++++ views_api.py | 11 +++++++++++ 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 053133d..a0659b5 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -839,12 +839,21 @@ window.app = Vue.createApp({ // ----------------------------------------------------------------- // Pair / revoke spire (S0 / #9, #12) // ----------------------------------------------------------------- - openPairDialog(machine) { + async openPairDialog(machine) { this.pairDialog.machine = machine this.pairDialog.relays = '' this.pairDialog.durationHours = null this.pairDialog.result = null this.pairDialog.show = true + // Pre-fill with this lnbits' default relay (the nostrclient proxy + // endpoint derived from lnbits_baseurl). The operator can override or + // clear it; if left blank the backend fills in the same default. + try { + const {data} = await LNbits.api.request('GET', `${API}/default-relay`) + if (data && data.relay) this.pairDialog.relays = data.relay + } catch (e) { + // Non-fatal: leave blank; the backend still defaults on submit. + } }, async submitPair() { @@ -852,14 +861,12 @@ window.app = Vue.createApp({ .split(/[\s,]+/) .map(s => s.trim()) .filter(Boolean) - if (!relays.length) { - Quasar.Notify.create({ - type: 'negative', - message: 'At least one relay is required' - }) - return + // relays is optional — blank falls back to the nostrclient endpoint + // server-side (the pre-filled default), so no client-side requirement. + const body = {} + if (relays.length) { + body.relays = relays } - const body = {relays} if (this.pairDialog.durationHours) { body.duration_hours = Number(this.pairDialog.durationHours) } diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index 01bb973..8399529 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -868,7 +868,7 @@ diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py index 775fd4f..c123a81 100644 --- a/tests/test_pair_endpoint.py +++ b/tests/test_pair_endpoint.py @@ -175,3 +175,15 @@ def test_revoke_failure_maps_to_bad_gateway(monkeypatch): _call_revoke() assert ei.value.status_code == 502 assert state["unpaired"] is None # not persisted on failure + + +def test_default_relay_endpoint_returns_nostrclient_url(): + from lnbits.settings import settings + + prev = settings.lnbits_baseurl + try: + settings.lnbits_baseurl = "https://lnbits.example.com/" + result = asyncio.run(views_api.api_default_relay(SimpleNamespace(id="op1"))) + assert result == {"relay": "wss://lnbits.example.com/nostrclient/api/v1/relay"} + finally: + settings.lnbits_baseurl = prev diff --git a/views_api.py b/views_api.py index ebaf53b..86748eb 100644 --- a/views_api.py +++ b/views_api.py @@ -33,6 +33,7 @@ from .pairing import ( PairResult, PairingError, RevokeResult, + default_relay_endpoint, pair_spire, revoke_spire, ) @@ -297,6 +298,16 @@ async def api_create_machine( return machine +@spirekeeper_api_router.get("/api/v1/dca/default-relay") +async def api_default_relay(user: User = Depends(check_user_exists)) -> dict: + """The relay a pairing seed defaults to when the operator leaves it blank — + this lnbits' nostrclient proxy endpoint, derived from lnbits_baseurl + (bitspire#70). The pair dialog pre-fills it. `None` if lnbits_baseurl is + unset.""" + _ = user + return {"relay": default_relay_endpoint()} + + @spirekeeper_api_router.post( "/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult ) From 765b07737b6d3e48f91a46500f22d3a64ee37db1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 00:29:08 +0200 Subject: [PATCH 5/7] fix(pairing): retry get_key_tokens past a transient empty result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pair_spire failed with "bunker returned no tokens after create_new_token" when the nsecbunkerd was briefly slow — get_key_tokens listed nothing in the few ms after create_new_token before the write landed. Observed live: a nip44_decrypt timeout, then a 502 on pair, then the identical call 25s later succeeding. Retry get_key_tokens up to 5x with a 0.4s backoff before giving up, so pairing survives a sluggish bunker instead of flaking. Tests cover the transient-empty recovery and the exhausted-attempts failure. Co-Authored-By: Claude Opus 4.8 --- pairing.py | 25 +++++++++++++++++++++++- tests/test_pairing.py | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/pairing.py b/pairing.py index a751e1d..c372117 100644 --- a/pairing.py +++ b/pairing.py @@ -42,6 +42,7 @@ with nothing else provisioned. See bitspire packages/nostr-client/src/seed.ts. from __future__ import annotations +import asyncio import base64 import json import re @@ -143,6 +144,28 @@ def _recover_token(tokens: list[dict], client_name: str) -> str: return token +async def _get_key_tokens_with_retry( + admin_client: NsecBunkerAdminClient, + key_name: str, + *, + attempts: int = 5, + delay: float = 0.4, +) -> list[dict]: + """`get_key_tokens` can race a just-issued token: when the bunker is briefly + slow (a `nip44_decrypt`/`create_new_token` under load), listing tokens + milliseconds later can return an empty set before the write lands, and + pairing fails with 'no tokens'. Retry a few times with a short backoff + before giving up (bitspire#70).""" + tokens: list[dict] = [] + for i in range(attempts): + tokens = await admin_client.get_key_tokens(key_name) + if tokens: + return tokens + if i < attempts - 1: + await asyncio.sleep(delay) + return tokens + + def build_seed_url( *, spire_npub: str, @@ -299,7 +322,7 @@ async def pair_spire( await admin_client.create_new_token( key_name, client_name, policy_id, duration_hours=duration_hours ) - tokens = await admin_client.get_key_tokens(key_name) + tokens = await _get_key_tokens_with_retry(admin_client, key_name) except NsecBunkerNotConfiguredError as exc: raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc except NsecBunkerError as exc: diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 15334fa..1f9207f 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -248,6 +248,50 @@ def test_malformed_token_raises(): _pair(bunker) +def test_get_key_tokens_retries_past_a_transient_empty(monkeypatch): + # A slow bunker can list no tokens right after create_new_token; retry. + import spirekeeper.pairing as pairing_mod + + async def _no_sleep(*_a, **_k): + pass + + monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep) + + bunker = FakeBunker(token_secret="s") # pragma: allowlist secret + real = bunker.get_key_tokens + calls = {"n": 0} + + async def flaky(key_name): + calls["n"] += 1 + return [] if calls["n"] == 1 else await real(key_name) + + bunker.get_key_tokens = flaky + result = _pair(bunker) + assert calls["n"] == 2 # empty once, then the token + assert result.spire_npub == _SPIRE_NPUB + + +def test_pair_raises_when_tokens_stay_empty(monkeypatch): + import spirekeeper.pairing as pairing_mod + + async def _no_sleep(*_a, **_k): + pass + + monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep) + + bunker = FakeBunker() + calls = {"n": 0} + + async def always_empty(key_name): + calls["n"] += 1 + return [] + + bunker.get_key_tokens = always_empty + with pytest.raises(PairingError, match="no tokens"): + _pair(bunker) + assert calls["n"] == 5 # exhausted all attempts + + def test_bunker_relay_defaults_to_spire_event_relay(): """No explicit bunker_relay -> the relay baked into bunker_url is the spire's own public event relay (relays[0]), NOT lnbits's internal bunker URL. This From 0bb9939822a91d90b9f176d96279b6308829453c Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 00:43:54 +0200 Subject: [PATCH 6/7] fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nostrclient endpoint is a subscription MULTIPLEXER, not a full relay: its router forwards a client's EVENT upstream but never returns an OK ack (see nostrclient/router.py). A transport client that awaits OK on publish therefore times out ("publish timed out"), so kind-21000 RPCs never complete — verified on the Sintra: connect succeeded but list_wallets hung, and switching to the nostrrelay endpoint made the whole flow work (wallet, balance, availability). default_relay_endpoint now derives from settings.nostr_transport_relays — the relay the transport actually listens on: use it as-is when already machine-reachable, or re-home its path on lnbits_baseurl when it's a co-located loopback relay (the bundled nostrrelay). Validation/localhost-reject and the pair-dialog pre-fill/hint carry over; wording updated to "transport relay". 227 tests pass. Co-Authored-By: Claude Opus 4.8 --- models.py | 4 +-- pairing.py | 62 ++++++++++++++++++++------------ templates/spirekeeper/index.html | 2 +- tests/test_pair_endpoint.py | 11 +++--- tests/test_pairing.py | 50 ++++++++++++++++++-------- views_api.py | 9 +++-- 6 files changed, 89 insertions(+), 49 deletions(-) diff --git a/models.py b/models.py index 34db4b6..5a4e529 100644 --- a/models.py +++ b/models.py @@ -94,8 +94,8 @@ class PairMachineData(BaseModel): the relay lnbits uses to reach the bunker differs from the one the spire must reach — e.g. an internal docker hostname (`ws://lnbits:5001/…`) vs a LAN/public URL (`ws://192.168.0.32:5001/…`), or any split-relay deploy. - `relays` is optional: when omitted it defaults to this lnbits' nostrclient - proxy endpoint (derived from `lnbits_baseurl`), so the operator needn't + `relays` is optional: when omitted it defaults to the relay the transport + listens on (derived from the transport config), so the operator needn't supply one (bitspire#70). `duration_hours` optionally time-bounds the spire's connect token (None = non-expiring).""" diff --git a/pairing.py b/pairing.py index c372117..bd694c9 100644 --- a/pairing.py +++ b/pairing.py @@ -199,25 +199,41 @@ def build_seed_url( _WS_SCHEME_RE = re.compile(r"^wss?://", re.IGNORECASE) _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"} -# The nostrclient extension serves a public relay-multiplexer websocket at this -# path (see nostrclient/views_api.py `ws_relay`, gated by config.public_ws). -NOSTRCLIENT_RELAY_PATH = "/nostrclient/api/v1/relay" + +def _baseurl_ws() -> str | None: + """`settings.lnbits_baseurl` as a ws(s):// scheme+host (no trailing slash).""" + base = (settings.lnbits_baseurl or "").strip().rstrip("/") + if base.startswith("https://"): + return "wss://" + base[len("https://") :] + if base.startswith("http://"): + return "ws://" + base[len("http://") :] + return None def default_relay_endpoint() -> str | None: - """Derive the machine-facing relay from THIS lnbits' own nostrclient proxy - endpoint, so operators configure upstream relays once in the nostrclient - extension and every seed points at one stable URL (bitspire#70). Built from - `settings.lnbits_baseurl` (http→ws, https→wss). Returns None when the base - URL is unset. Requires the nostrclient extension's `public_ws` to be on.""" - base = (settings.lnbits_baseurl or "").strip().rstrip("/") + """Derive the machine-facing relay from lnbits' nostr-transport config, so a + seed points at the relay the transport ACTUALLY listens on (bitspire#70). + + NOTE: this is the nostr *relay* (a full relay that acks EVENTs), NOT the + nostrclient proxy — the client multiplexer forwards subscriptions but never + sends `OK`, so a transport client that awaits `OK` on publish times out. + + If the transport relay is already machine-reachable, use it as-is; if it's a + co-located loopback relay (the bundled nostrrelay at + `ws://localhost:5001/nostrrelay/`), re-home its path on lnbits' public + base URL so a remote ATM can reach the same relay. Returns None if neither + a transport relay nor a base URL is available.""" + relays = settings.nostr_transport_relays or [] + if not relays: + return None + relay = relays[0] + host = (urlparse(relay).hostname or "").lower() + if host and host not in _LOCAL_HOSTS and not host.endswith(".localhost"): + return relay # already remote-reachable + base = _baseurl_ws() if not base: return None - if base.startswith("https://"): - base = "wss://" + base[len("https://") :] - elif base.startswith("http://"): - base = "ws://" + base[len("http://") :] - return base + NOSTRCLIENT_RELAY_PATH + return base + urlparse(relay).path def _validate_relay(url: str, field: str) -> None: @@ -262,11 +278,11 @@ async def pair_spire( with a fake client. `relays` are the relays the spire uses for its *own* events - (kind-21000/30078). When omitted, they default to this lnbits' nostrclient - proxy endpoint (`default_relay_endpoint`, derived from `lnbits_baseurl`) so - operators configure upstream relays once in the extension and every seed - points at one stable URL. Every relay (and `bunker_relay`) is validated as a - remote-reachable `ws(s)://` URL — a loopback host is rejected (bitspire#70). + (kind-21000/30078). When omitted, they default to the relay the transport + listens on (`default_relay_endpoint`, from `nostr_transport_relays` re-homed + on `lnbits_baseurl`) so a seed points at a relay that actually acks EVENTs. + Every relay (and `bunker_relay`) is validated as a remote-reachable + `ws(s)://` URL — a loopback host is rejected (bitspire#70). `bunker_relay` (the relay baked into `bunker_url`, where the spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase` defaults to the lnbits bunker setting. All injectable for tests. @@ -284,14 +300,14 @@ async def pair_spire( "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " "cannot mint a spire key" ) - # Default the spire's event relay(s) to this lnbits' nostrclient proxy - # endpoint when the caller gives none. + # Default the spire's event relay(s) to the transport's own relay (re-homed + # on the base URL) when the caller gives none. if not relays: endpoint = default_relay_endpoint() if not endpoint: raise PairingError( - "no relays given and lnbits_baseurl is unset — cannot derive the " - "nostrclient relay endpoint for the seed" + "no relays given and none could be derived — set " + "nostr_transport_relays (and lnbits_baseurl if it's a loopback relay)" ) relays = [endpoint] diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index 8399529..322f4f2 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -868,7 +868,7 @@ diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py index c123a81..682cdd8 100644 --- a/tests/test_pair_endpoint.py +++ b/tests/test_pair_endpoint.py @@ -177,13 +177,16 @@ def test_revoke_failure_maps_to_bad_gateway(monkeypatch): assert state["unpaired"] is None # not persisted on failure -def test_default_relay_endpoint_returns_nostrclient_url(): +def test_default_relay_endpoint_returns_transport_relay(): from lnbits.settings import settings - prev = settings.lnbits_baseurl + prev_base = settings.lnbits_baseurl + prev_relays = settings.nostr_transport_relays try: settings.lnbits_baseurl = "https://lnbits.example.com/" + settings.nostr_transport_relays = ["ws://localhost:5001/nostrrelay/test"] result = asyncio.run(views_api.api_default_relay(SimpleNamespace(id="op1"))) - assert result == {"relay": "wss://lnbits.example.com/nostrclient/api/v1/relay"} + assert result == {"relay": "wss://lnbits.example.com/nostrrelay/test"} finally: - settings.lnbits_baseurl = prev + settings.lnbits_baseurl = prev_base + settings.nostr_transport_relays = prev_relays diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 1f9207f..967e7af 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -35,7 +35,9 @@ from ..pairing import ( ) _BASEURL = "https://lnbits.example.com/" -_NOSTRCLIENT_ENDPOINT = "wss://lnbits.example.com/nostrclient/api/v1/relay" +_TRANSPORT_RELAY = "ws://localhost:5001/nostrrelay/test" +# baseurl host (wss://lnbits.example.com) + transport relay path (/nostrrelay/test) +_DEFAULT_RELAY = "wss://lnbits.example.com/nostrrelay/test" _NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) _SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" @@ -53,11 +55,14 @@ def _set_transport_pubkey(): # 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 + prev_relays = settings.nostr_transport_relays settings.nostr_transport_public_key = _LNBITS_HEX settings.lnbits_baseurl = _BASEURL + settings.nostr_transport_relays = [_TRANSPORT_RELAY] yield settings.nostr_transport_public_key = prev settings.lnbits_baseurl = prev_base + settings.nostr_transport_relays = prev_relays @pytest.fixture(autouse=True) @@ -327,8 +332,8 @@ def test_missing_passphrase_raises(): ) -def test_relays_default_to_nostrclient_endpoint(): - # No relays given → derived from lnbits_baseurl → nostrclient proxy endpoint. +def test_relays_default_to_transport_relay(): + # No relays given → derived from the transport relay, re-homed on baseurl. result = asyncio.run( pair_spire( _machine(), @@ -336,34 +341,51 @@ def test_relays_default_to_nostrclient_endpoint(): keystore_passphrase=_PASSPHRASE, ) ) - assert _decode_seed(result.seed_url)["relays"] == [_NOSTRCLIENT_ENDPOINT] + assert _decode_seed(result.seed_url)["relays"] == [_DEFAULT_RELAY] -def test_default_relay_endpoint_scheme_map(): - prev = settings.lnbits_baseurl +def test_default_relay_endpoint_rehomes_loopback_transport_relay(): + prev_base = settings.lnbits_baseurl + prev_relays = settings.nostr_transport_relays try: + # loopback transport relay → re-homed on the base URL host + settings.nostr_transport_relays = ["ws://localhost:5001/nostrrelay/test"] settings.lnbits_baseurl = "http://192.168.0.32:5001" - assert default_relay_endpoint() == "ws://192.168.0.32:5001/nostrclient/api/v1/relay" + assert default_relay_endpoint() == "ws://192.168.0.32:5001/nostrrelay/test" settings.lnbits_baseurl = "https://lnbits.example.com/" - assert default_relay_endpoint() == _NOSTRCLIENT_ENDPOINT + assert default_relay_endpoint() == _DEFAULT_RELAY + # loopback relay but no base URL → can't re-home → None settings.lnbits_baseurl = "" assert default_relay_endpoint() is None finally: - settings.lnbits_baseurl = prev + settings.lnbits_baseurl = prev_base + settings.nostr_transport_relays = prev_relays -def test_no_relays_and_no_baseurl_raises(): - prev = settings.lnbits_baseurl +def test_default_relay_endpoint_passes_through_reachable_transport_relay(): + prev = settings.nostr_transport_relays try: - settings.lnbits_baseurl = "" - with pytest.raises(PairingError, match="cannot derive"): + # already remote-reachable → used as-is, base URL irrelevant + settings.nostr_transport_relays = ["wss://relay.aiolabs.dev"] + assert default_relay_endpoint() == "wss://relay.aiolabs.dev" + settings.nostr_transport_relays = [] + assert default_relay_endpoint() is None + finally: + settings.nostr_transport_relays = prev + + +def test_no_relays_and_no_transport_relay_raises(): + prev = settings.nostr_transport_relays + try: + settings.nostr_transport_relays = [] + with pytest.raises(PairingError, match="none could be derived"): asyncio.run( pair_spire( _machine(), admin_client=FakeBunker(), keystore_passphrase=_PASSPHRASE ) ) finally: - settings.lnbits_baseurl = prev + settings.nostr_transport_relays = prev @pytest.mark.parametrize( diff --git a/views_api.py b/views_api.py index 86748eb..73b2d0b 100644 --- a/views_api.py +++ b/views_api.py @@ -301,9 +301,8 @@ async def api_create_machine( @spirekeeper_api_router.get("/api/v1/dca/default-relay") async def api_default_relay(user: User = Depends(check_user_exists)) -> dict: """The relay a pairing seed defaults to when the operator leaves it blank — - this lnbits' nostrclient proxy endpoint, derived from lnbits_baseurl - (bitspire#70). The pair dialog pre-fills it. `None` if lnbits_baseurl is - unset.""" + the relay the transport listens on, derived from the transport config + (bitspire#70). The pair dialog pre-fills it. `None` if it can't be derived.""" _ = user return {"relay": default_relay_endpoint()} @@ -326,8 +325,8 @@ async def api_pair_machine( `duration_hours` (optional) time-bounds the token; revoke via the sibling `POST .../revoke` endpoint.""" machine = await _machine_owned_by(machine_id, user.id) - # relays may be omitted — pair_spire defaults to this lnbits' nostrclient - # proxy endpoint and validates reachability (raises PairingError → 502). + # relays may be omitted — pair_spire defaults to the transport relay and + # validates reachability (raises PairingError → 502). try: async with NsecBunkerAdminClient.from_settings() as client: From 2b90590104b4d816f75f65cfdaa027c4dae53dcf Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 15:30:55 +0200 Subject: [PATCH 7/7] fix(pairing): retry all bunker admin RPCs past transient timeouts (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The get_key_tokens retry only covered an empty token list; a transient NsecBunkerTimeoutError on any admin RPC still failed pairing with a 502 (seen live on aio-demo: create_new_key and get_key_tokens both 15s-timed-out, then a manual retry succeeded). Generalise to `_bunker_retry`, wrapping every admin call in the pair_spire chain (create_new_key, ensure_policy, create_new_token, get_key_tokens): a NsecBunkerTimeoutError (and, for get_key_tokens, an empty list) is transient → retry with backoff; a NsecBunkerRpcError rejection or misconfig is terminal → fail fast. create_new_key is replace-by-name and ensure_policy reconciles idempotently, so retrying on timeout is safe. Also: _validate_relay now rejects a host-less ws:// (mint was laxer than the consumer's parseSpireSeed), and stale "nostrclient" comments in the pair UI + a test are corrected to "transport relay". 229 tests pass (incl. timeout-retry + fail-fast-rejection coverage). Co-Authored-By: Claude Opus 4.8 --- pairing.py | 68 ++++++++++++++++++++++--------------- static/js/index.js | 6 ++-- tests/test_pair_endpoint.py | 2 +- tests/test_pairing.py | 49 ++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 32 deletions(-) diff --git a/pairing.py b/pairing.py index bd694c9..bc49eb2 100644 --- a/pairing.py +++ b/pairing.py @@ -52,6 +52,7 @@ from lnbits.core.services.nsec_bunker import ( NsecBunkerAdminClient, NsecBunkerError, NsecBunkerNotConfiguredError, + NsecBunkerTimeoutError, npub_to_hex, ) from lnbits.core.signers.remote_bunker import ensure_policy @@ -144,26 +145,27 @@ def _recover_token(tokens: list[dict], client_name: str) -> str: return token -async def _get_key_tokens_with_retry( - admin_client: NsecBunkerAdminClient, - key_name: str, - *, - attempts: int = 5, - delay: float = 0.4, -) -> list[dict]: - """`get_key_tokens` can race a just-issued token: when the bunker is briefly - slow (a `nip44_decrypt`/`create_new_token` under load), listing tokens - milliseconds later can return an empty set before the write lands, and - pairing fails with 'no tokens'. Retry a few times with a short backoff - before giving up (bitspire#70).""" - tokens: list[dict] = [] - for i in range(attempts): - tokens = await admin_client.get_key_tokens(key_name) - if tokens: - return tokens - if i < attempts - 1: +async def _bunker_retry(call, *, attempts: int = 5, delay: float = 0.4, retry_empty: bool = False): + """Retry a bunker admin RPC past a TRANSIENT failure — a `NsecBunkerTimeoutError` + (the nsecbunkerd briefly slow / unresponsive) and, when `retry_empty`, an empty + result (a just-issued token not yet listed). Both are seen intermittently on a + healthy bunker (bitspire#70 / spirekeeper#38). A real rejection + (`NsecBunkerRpcError`) or misconfig (`NsecBunkerNotConfiguredError`) is terminal + and propagates immediately. `call` is a zero-arg coroutine factory.""" + for attempt in range(attempts): + last = attempt == attempts - 1 + try: + result = await call() + except NsecBunkerTimeoutError: + if last: + raise await asyncio.sleep(delay) - return tokens + continue + if retry_empty and not result and not last: + await asyncio.sleep(delay) + continue + return result + raise AssertionError("unreachable: the final attempt returns or raises") def build_seed_url( @@ -243,6 +245,8 @@ def _validate_relay(url: str, field: str) -> None: if not isinstance(url, str) or not _WS_SCHEME_RE.match(url): raise PairingError(f"{field} must be a ws:// or wss:// URL (got {url!r})") host = (urlparse(url).hostname or "").lower() + if not host: + raise PairingError(f"{field} has no host ({url!r})") if host in _LOCAL_HOSTS or host.endswith(".localhost"): raise PairingError( f"{field} points at localhost ({url!r}). A pairing seed is redeemed by " @@ -327,18 +331,26 @@ async def pair_spire( client_name = f"spire-client-{machine.id}" try: - spire_npub = await admin_client.create_new_key(key_name, passphrase) + # Each admin RPC is retried past a transient bunker timeout (#38); + # create_new_key is replace-by-name so re-issuing on a timeout is safe, + # and ensure_policy reconciles idempotently (get_policies → reuse/create). + spire_npub = await _bunker_retry(lambda: admin_client.create_new_key(key_name, passphrase)) spire_pubkey_hex = npub_to_hex(spire_npub) - policy_id = await ensure_policy( - admin_client, - name=SPIRE_POLICY_NAME, - rules=SPIRE_POLICY_RULES, - methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND, + policy_id = await _bunker_retry( + lambda: ensure_policy( + admin_client, + name=SPIRE_POLICY_NAME, + rules=SPIRE_POLICY_RULES, + methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND, + ) ) - await admin_client.create_new_token( - key_name, client_name, policy_id, duration_hours=duration_hours + await _bunker_retry( + lambda: admin_client.create_new_token( + key_name, client_name, policy_id, duration_hours=duration_hours + ) ) - tokens = await _get_key_tokens_with_retry(admin_client, key_name) + # retry_empty: a just-issued token can list empty before the write lands. + tokens = await _bunker_retry(lambda: admin_client.get_key_tokens(key_name), retry_empty=True) except NsecBunkerNotConfiguredError as exc: raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc except NsecBunkerError as exc: diff --git a/static/js/index.js b/static/js/index.js index a0659b5..09188ac 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -845,8 +845,8 @@ window.app = Vue.createApp({ this.pairDialog.durationHours = null this.pairDialog.result = null this.pairDialog.show = true - // Pre-fill with this lnbits' default relay (the nostrclient proxy - // endpoint derived from lnbits_baseurl). The operator can override or + // Pre-fill with this lnbits' default relay (the relay its nostr transport + // listens on, derived from lnbits_baseurl). The operator can override or // clear it; if left blank the backend fills in the same default. try { const {data} = await LNbits.api.request('GET', `${API}/default-relay`) @@ -861,7 +861,7 @@ window.app = Vue.createApp({ .split(/[\s,]+/) .map(s => s.trim()) .filter(Boolean) - // relays is optional — blank falls back to the nostrclient endpoint + // relays is optional — blank falls back to the transport relay // server-side (the pre-filled default), so no client-side requirement. const body = {} if (relays.length) { diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py index 682cdd8..51529e5 100644 --- a/tests/test_pair_endpoint.py +++ b/tests/test_pair_endpoint.py @@ -115,7 +115,7 @@ def test_pair_persists_hex_npub_and_returns_seed(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. + # transport relay (and validates), so the endpoint passes them through. _wire(monkeypatch) result = _call([]) assert result.seed_url == "spire-seed:v1:abc" diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 967e7af..12c7dfc 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -297,6 +297,55 @@ def test_pair_raises_when_tokens_stay_empty(monkeypatch): assert calls["n"] == 5 # exhausted all attempts +def test_create_new_key_retries_past_transient_timeout(monkeypatch): + # A bunker timeout on any admin RPC (here create_new_key) is transient (#38). + import spirekeeper.pairing as pairing_mod + from lnbits.core.services.nsec_bunker import NsecBunkerTimeoutError + + async def _no_sleep(*_a, **_k): + pass + + monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep) + + bunker = FakeBunker(token_secret="s") # pragma: allowlist secret + real = bunker.create_new_key + calls = {"n": 0} + + async def flaky(name, passphrase): + calls["n"] += 1 + if calls["n"] == 1: + raise NsecBunkerTimeoutError("no response for 'create_new_key' within 15.0s") + return await real(name, passphrase) + + bunker.create_new_key = flaky + result = _pair(bunker) + assert calls["n"] == 2 # timed out once, then succeeded + assert result.spire_npub == _SPIRE_NPUB + + +def test_bunker_rejection_fails_fast_without_retry(monkeypatch): + # A real rejection (NsecBunkerRpcError) is terminal — no retry. + import spirekeeper.pairing as pairing_mod + from lnbits.core.services.nsec_bunker import NsecBunkerRpcError + + async def _no_sleep(*_a, **_k): + pass + + monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep) + + bunker = FakeBunker() + calls = {"n": 0} + + async def rejecting(name, passphrase): + calls["n"] += 1 + raise NsecBunkerRpcError("policy rejected") + + bunker.create_new_key = rejecting + with pytest.raises(PairingError, match="bunker admin RPC failed"): + _pair(bunker) + assert calls["n"] == 1 # terminal — not retried + + def test_bunker_relay_defaults_to_spire_event_relay(): """No explicit bunker_relay -> the relay baked into bunker_url is the spire's own public event relay (relays[0]), NOT lnbits's internal bunker URL. This