Compare commits

..

No commits in common. "8b64e9c7425ab7da2bdc992830104c0d162a35f1" and "a5ab02e4b6b500522e020840c791c518c4a91eb1" have entirely different histories.

7 changed files with 67 additions and 474 deletions

View file

@ -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

View file

@ -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:<base64url(json)> json = {
"v": 1,
"spire_npub": "npub1…", # the bunker-minted spire identity
"lnbits_npub": "npub1…", # this lnbits' nostr-transport server id
"bunker_secret": "<sec>", # one-shot NIP-46 connect token
"spire_pubkey": "<64-hex>", # same key, hex (consumer convenience)
"bunker_url": "bunker://<spire_pubkey>?relay=<bunker_relay>&secret=<sec>",
"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
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/<id>`), 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(
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(
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,

View file

@ -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)
}

View file

@ -868,7 +868,7 @@
<q-input
v-model="pairDialog.relays"
label="Relay(s) for the spire's events"
hint="Pre-filled with the relay this instance's nostr transport listens on. Leave as-is unless the spire must use a different relay. One per line; blank uses the default."
hint="One per line. The same relay the spire publishes to (its VITE_RELAY_URL), e.g. wss://your-host/nostrrelay/<id>"
type="textarea" autogrow
class="q-mb-md"
dense outlined></q-input>

View file

@ -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

View file

@ -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():

View file

@ -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: