diff --git a/models.py b/models.py index 5a4e529..99ee552 100644 --- a/models.py +++ b/models.py @@ -94,12 +94,10 @@ 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 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).""" + `duration_hours` optionally time-bounds the spire's connect token + (None = non-expiring).""" - relays: list[str] | None = None + relays: list[str] bunker_relay: str | None = None duration_hours: int | None = None diff --git a/pairing.py b/pairing.py index bc49eb2..c2d923f 100644 --- a/pairing.py +++ b/pairing.py @@ -23,41 +23,31 @@ 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, slimmed in bitspire#70): +Seed URL wire format (contract shared with bitspire#52): spire-seed:v1: json = { "v": 1, - "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] + "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 } - -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 -import asyncio import base64 import json -import re -from urllib.parse import quote, urlparse +from urllib.parse import quote from lnbits.core.services.nsec_bunker import ( NsecBunkerAdminClient, NsecBunkerError, NsecBunkerNotConfiguredError, - NsecBunkerTimeoutError, npub_to_hex, ) 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 @@ -145,51 +135,16 @@ def _recover_token(tokens: list[dict], client_name: str) -> str: return token -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) - 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( - *, - spire_npub: str, - lnbits_npub: str, - bunker_secret: str, - relays: list[str], - bunker_relay: str | None = None, + *, spire_npub: str, spire_pubkey_hex: str, bunker_url: str, relays: list[str] ) -> str: - """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 = { + payload = { "v": 1, "spire_npub": spire_npub, - "lnbits_npub": lnbits_npub, - "bunker_secret": bunker_secret, + "spire_pubkey": spire_pubkey_hex, + "bunker_url": bunker_url, "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() @@ -198,67 +153,10 @@ 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"} - - -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 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 - return base + urlparse(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 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 " - "a REMOTE machine — set lnbits_baseurl (and any explicit relay) to a " - "machine-reachable address." - ) - - async def pair_spire( machine: Machine, *, - relays: list[str] | None = None, + relays: list[str], admin_client: NsecBunkerAdminClient, bunker_relay: str | None = None, keystore_passphrase: str | None = None, @@ -282,14 +180,10 @@ 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 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. + (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. Raises PairingError on any bunker failure; no state is persisted here (the API layer persists on success). @@ -304,53 +198,33 @@ 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 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 none could be derived — set " - "nostr_transport_relays (and lnbits_baseurl if it's a loopback relay)" - ) - 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") + 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. relay = bunker_relay if bunker_relay else relays[0] key_name = spire_key_name(machine.id) client_name = f"spire-client-{machine.id}" try: - # 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_npub = await admin_client.create_new_key(key_name, passphrase) spire_pubkey_hex = npub_to_hex(spire_npub) - 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, - ) + policy_id = await ensure_policy( + admin_client, + name=SPIRE_POLICY_NAME, + rules=SPIRE_POLICY_RULES, + methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND, ) - await _bunker_retry( - lambda: admin_client.create_new_token( - key_name, client_name, policy_id, duration_hours=duration_hours - ) + await admin_client.create_new_token( + key_name, client_name, policy_id, duration_hours=duration_hours ) - # 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) + tokens = await admin_client.get_key_tokens(key_name) except NsecBunkerNotConfiguredError as exc: raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc except NsecBunkerError as exc: @@ -359,31 +233,15 @@ 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, - lnbits_npub=lnbits_npub, - bunker_secret=secret, + spire_pubkey_hex=spire_pubkey_hex, + bunker_url=bunker_url, relays=relays, - bunker_relay=relay, ) return PairResult( spire_npub=spire_npub, diff --git a/static/js/index.js b/static/js/index.js index 09188ac..053133d 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -839,21 +839,12 @@ window.app = Vue.createApp({ // ----------------------------------------------------------------- // Pair / revoke spire (S0 / #9, #12) // ----------------------------------------------------------------- - async openPairDialog(machine) { + 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 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`) - if (data && data.relay) this.pairDialog.relays = data.relay - } catch (e) { - // Non-fatal: leave blank; the backend still defaults on submit. - } }, async submitPair() { @@ -861,12 +852,14 @@ window.app = Vue.createApp({ .split(/[\s,]+/) .map(s => s.trim()) .filter(Boolean) - // 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) { - body.relays = relays + if (!relays.length) { + Quasar.Notify.create({ + type: 'negative', + message: 'At least one relay is required' + }) + return } + 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 322f4f2..01bb973 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 51529e5..0d50d95 100644 --- a/tests/test_pair_endpoint.py +++ b/tests/test_pair_endpoint.py @@ -66,9 +66,7 @@ def _wire(monkeypatch, *, pair="ok"): async def fake_owned(machine_id, user_id): return _machine() - async def fake_pair( - machine, *, relays, admin_client, bunker_relay=None, duration_hours=None - ): + async def fake_pair(machine, *, relays, admin_client, duration_hours=None): if pair == "error": raise PairingError("boom") return _result() @@ -82,18 +80,11 @@ 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 @@ -113,12 +104,11 @@ def test_pair_persists_hex_npub_and_returns_seed(monkeypatch): assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1") -def test_pair_empty_relays_accepted(monkeypatch): - # Empty/omitted relays are no longer a 400 — pair_spire defaults them to the - # transport relay (and validates), so the endpoint passes them through. +def test_pair_empty_relays_rejected(monkeypatch): _wire(monkeypatch) - result = _call([]) - assert result.seed_url == "spire-seed:v1:abc" + with pytest.raises(HTTPException) as ei: + _call([]) + assert ei.value.status_code == 400 def test_pair_failure_maps_to_bad_gateway(monkeypatch): @@ -175,18 +165,3 @@ 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_transport_relay(): - from lnbits.settings import settings - - 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/nostrrelay/test"} - finally: - settings.lnbits_baseurl = prev_base - settings.nostr_transport_relays = prev_relays diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 12c7dfc..e3d2999 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -17,7 +17,6 @@ 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 @@ -28,43 +27,19 @@ 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/" -_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" _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 - 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) def _clear_policy_cache(): # lnbits' ensure_policy caches resolved policy ids on @@ -182,28 +157,18 @@ 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 - payload = _decode_seed(result.seed_url) + 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))) assert payload == { "v": 1, "spire_npub": _SPIRE_NPUB, - "lnbits_npub": _LNBITS_NPUB, - "bunker_secret": "zzz", + "spire_pubkey": _SPIRE_HEX, + "bunker_url": result.bunker_url, "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(): @@ -253,99 +218,6 @@ 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_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 @@ -368,7 +240,7 @@ def test_bunker_relay_defaults_to_spire_event_relay(): assert "127.0.0.1" not in result.bunker_url -def test_missing_passphrase_raises(): +def test_missing_relay_or_passphrase_raises(): with pytest.raises(PairingError, match="PASSPHRASE"): asyncio.run( pair_spire( @@ -379,80 +251,13 @@ def test_missing_passphrase_raises(): keystore_passphrase="", ) ) - - -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(), - admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret - keystore_passphrase=_PASSPHRASE, - ) - ) - assert _decode_seed(result.seed_url)["relays"] == [_DEFAULT_RELAY] - - -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/nostrrelay/test" - settings.lnbits_baseurl = "https://lnbits.example.com/" - 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_base - settings.nostr_transport_relays = prev_relays - - -def test_default_relay_endpoint_passes_through_reachable_transport_relay(): - prev = settings.nostr_transport_relays - try: - # 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.nostr_transport_relays = 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): + with pytest.raises(PairingError, match="relay is required"): asyncio.run( pair_spire( _machine(), - relays=[bad_relay], + relays=[], admin_client=FakeBunker(), + bunker_relay=_BUNKER_RELAY, keystore_passphrase=_PASSPHRASE, ) ) @@ -461,40 +266,14 @@ def test_rejects_bad_relay(bad_relay, match): def test_build_seed_url_roundtrip(): url = build_seed_url( spire_npub=_SPIRE_NPUB, - lnbits_npub=_LNBITS_NPUB, - bunker_secret="s", # pragma: allowlist secret + spire_pubkey_hex=_SPIRE_HEX, + bunker_url="bunker://x?relay=r&secret=s", relays=_RELAYS, - bunker_relay=_BUNKER_RELAY, ) - payload = _decode_seed(url) - assert payload["spire_npub"] == _SPIRE_NPUB - assert payload["lnbits_npub"] == _LNBITS_NPUB - assert payload["bunker_secret"] == "s" + blob = url[len(SEED_URL_SCHEME) :] + payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) + assert payload["spire_pubkey"] == _SPIRE_HEX 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(): diff --git a/views_api.py b/views_api.py index 73b2d0b..35d35b2 100644 --- a/views_api.py +++ b/views_api.py @@ -33,7 +33,6 @@ from .pairing import ( PairResult, PairingError, RevokeResult, - default_relay_endpoint, pair_spire, revoke_spire, ) @@ -298,15 +297,6 @@ 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 — - 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()} - - @spirekeeper_api_router.post( "/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult ) @@ -325,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) - # relays may be omitted — pair_spire defaults to the transport relay and - # validates reachability (raises PairingError → 502). + if not data.relays: + raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required") try: async with NsecBunkerAdminClient.from_settings() as client: