Compare commits

...

8 commits

Author SHA1 Message Date
8b64e9c742 Merge pull request 'feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)' (#37) from bitspire-70-seed-lnbits-npub into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)' (#37) from bitspire-70-seed-lnbits-npub into main (push) Failing after 0s
Reviewed-on: #37
2026-07-02 17:45:01 +00:00
2b90590104 fix(pairing): retry all bunker admin RPCs past transient timeouts (#38)
Some checks failed
ci.yml / fix(pairing): retry all bunker admin RPCs past transient timeouts (#38) (pull_request) Failing after 0s
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 <noreply@anthropic.com>
2026-07-02 15:30:55 +02:00
0bb9939822 fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy
Some checks failed
ci.yml / fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy (pull_request) Failing after 0s
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 <noreply@anthropic.com>
2026-07-02 00:43:54 +02:00
765b07737b fix(pairing): retry get_key_tokens past a transient empty result
Some checks failed
ci.yml / fix(pairing): retry get_key_tokens past a transient empty result (pull_request) Failing after 0s
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 <noreply@anthropic.com>
2026-07-02 00:29:08 +02:00
4e36b98534 feat(pairing-ui): pre-fill the pair dialog relay with the default endpoint
Some checks failed
ci.yml / feat(pairing-ui): pre-fill the pair dialog relay with the default endpoint (pull_request) Failing after 0s
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)://<host>/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 <noreply@anthropic.com>
2026-07-02 00:16:46 +02:00
c3791ed6c8 feat(pairing): default seed relay to the nostrclient endpoint + validate relays
Some checks failed
ci.yml / feat(pairing): default seed relay to the nostrclient endpoint + validate relays (pull_request) Failing after 0s
Two robustness fixes for on-machine pairing (bitspire-#70), after a QR scan
silently corrupted a seed's relay (ws://→As://) and crash-looped a machine on
an unreachable relay:

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

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

223 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:00:13 +02:00
9dc4d09973 feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)
Some checks failed
ci.yml / feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70) (pull_request) Failing after 0s
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 <noreply@anthropic.com>
2026-07-01 21:16:10 +02:00
b93e3fb698 test(pair-endpoint): update stale pair_spire double + mock get_super_config
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 <noreply@anthropic.com>
2026-07-01 21:16:10 +02:00
7 changed files with 474 additions and 67 deletions

View file

@ -94,10 +94,12 @@ class PairMachineData(BaseModel):
the relay lnbits uses to reach the bunker differs from the one the spire 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 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. LAN/public URL (`ws://192.168.0.32:5001/`), or any split-relay deploy.
`duration_hours` optionally time-bounds the spire's connect token `relays` is optional: when omitted it defaults to the relay the transport
(None = non-expiring).""" 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)."""
relays: list[str] relays: list[str] | None = None
bunker_relay: str | None = None bunker_relay: str | None = None
duration_hours: int | None = None duration_hours: int | None = None

View file

@ -23,31 +23,41 @@ 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 NIP-46 client, so the binding must happen spire-side with the spire's own
client keypair. spirekeeper only mints + packages. 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:<base64url(json)> json = { spire-seed:v1:<base64url(json)> json = {
"v": 1, "v": 1,
"spire_npub": "npub1…", # the bunker-minted spire identity "spire_npub": "npub1…", # the bunker-minted spire identity
"spire_pubkey": "<64-hex>", # same key, hex (consumer convenience) "lnbits_npub": "npub1…", # this lnbits' nostr-transport server id
"bunker_url": "bunker://<spire_pubkey>?relay=<bunker_relay>&secret=<sec>", "bunker_secret": "<sec>", # one-shot NIP-46 connect token
"relays": ["wss://…"], # relays for the spire's own events "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 from __future__ import annotations
import asyncio
import base64 import base64
import json import json
from urllib.parse import quote import re
from urllib.parse import quote, urlparse
from lnbits.core.services.nsec_bunker import ( from lnbits.core.services.nsec_bunker import (
NsecBunkerAdminClient, NsecBunkerAdminClient,
NsecBunkerError, NsecBunkerError,
NsecBunkerNotConfiguredError, NsecBunkerNotConfiguredError,
NsecBunkerTimeoutError,
npub_to_hex, npub_to_hex,
) )
from lnbits.core.signers.remote_bunker import ensure_policy from lnbits.core.signers.remote_bunker import ensure_policy
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.nostr import hex_to_npub
from pydantic import BaseModel from pydantic import BaseModel
from .models import Machine from .models import Machine
@ -135,16 +145,51 @@ def _recover_token(tokens: list[dict], client_name: str) -> str:
return token 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( 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: ) -> 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, "v": 1,
"spire_npub": spire_npub, "spire_npub": spire_npub,
"spire_pubkey": spire_pubkey_hex, "lnbits_npub": lnbits_npub,
"bunker_url": bunker_url, "bunker_secret": bunker_secret,
"relays": relays, "relays": relays,
} }
if bunker_relay and relays and bunker_relay != relays[0]:
payload["bunker_relay"] = bunker_relay
blob = ( blob = (
base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()) base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode())
.decode() .decode()
@ -153,10 +198,67 @@ def build_seed_url(
return SEED_URL_SCHEME + blob 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( async def pair_spire(
machine: Machine, machine: Machine,
*, *,
relays: list[str], relays: list[str] | None = None,
admin_client: NsecBunkerAdminClient, admin_client: NsecBunkerAdminClient,
bunker_relay: str | None = None, bunker_relay: str | None = None,
keystore_passphrase: str | None = None, keystore_passphrase: str | None = None,
@ -180,10 +282,14 @@ async def pair_spire(
with a fake client. with a fake client.
`relays` are the relays the spire uses for its *own* events `relays` are the relays the spire uses for its *own* events
(kind-21000/30078) typically the operator's public nostrrelay; supplied by (kind-21000/30078). When omitted, they default to the relay the transport
the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the listens on (`default_relay_endpoint`, from `nostr_transport_relays` re-homed
spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase` on `lnbits_baseurl`) so a seed points at a relay that actually acks EVENTs.
defaults to the lnbits bunker setting. Both injectable for tests. 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 Raises PairingError on any bunker failure; no state is persisted here
(the API layer persists on success). (the API layer persists on success).
@ -198,33 +304,53 @@ async def pair_spire(
"LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — "
"cannot mint a spire key" "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: if not relays:
raise PairingError("at least one relay is required for the seed URL") endpoint = default_relay_endpoint()
# The relay baked into `bunker_url` is where the *spire* (the remote ATM) if not endpoint:
# reaches the bunker, so it must be a machine-reachable public URL — NOT raise PairingError(
# `settings.lnbits_nsec_bunker_url`, which is how the co-located lnbits "no relays given and none could be derived — set "
# reaches the bunker (typically ws://127.0.0.1, unreachable from the ATM — "nostr_transport_relays (and lnbits_baseurl if it's a loopback relay)"
# 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 relays = [endpoint]
# publishes to); an explicit `bunker_relay` overrides for split-relay deploys.
# 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] relay = bunker_relay if bunker_relay else relays[0]
key_name = spire_key_name(machine.id) key_name = spire_key_name(machine.id)
client_name = f"spire-client-{machine.id}" client_name = f"spire-client-{machine.id}"
try: 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) spire_pubkey_hex = npub_to_hex(spire_npub)
policy_id = await ensure_policy( policy_id = await _bunker_retry(
admin_client, lambda: ensure_policy(
name=SPIRE_POLICY_NAME, admin_client,
rules=SPIRE_POLICY_RULES, name=SPIRE_POLICY_NAME,
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND, rules=SPIRE_POLICY_RULES,
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
)
) )
await admin_client.create_new_token( await _bunker_retry(
key_name, client_name, policy_id, duration_hours=duration_hours lambda: admin_client.create_new_token(
key_name, client_name, policy_id, duration_hours=duration_hours
)
) )
tokens = await admin_client.get_key_tokens(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: except NsecBunkerNotConfiguredError as exc:
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
except NsecBunkerError as exc: except NsecBunkerError as exc:
@ -233,15 +359,31 @@ async def pair_spire(
token = _recover_token(tokens, client_name) token = _recover_token(tokens, client_name)
_, _, secret = token.partition("#") _, _, 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 = ( bunker_url = (
f"bunker://{spire_pubkey_hex}?relay={quote(relay, safe='')}" f"bunker://{spire_pubkey_hex}?relay={quote(relay, safe='')}"
f"&secret={quote(secret, safe='')}" f"&secret={quote(secret, safe='')}"
) )
seed_url = build_seed_url( seed_url = build_seed_url(
spire_npub=spire_npub, spire_npub=spire_npub,
spire_pubkey_hex=spire_pubkey_hex, lnbits_npub=lnbits_npub,
bunker_url=bunker_url, bunker_secret=secret,
relays=relays, relays=relays,
bunker_relay=relay,
) )
return PairResult( return PairResult(
spire_npub=spire_npub, spire_npub=spire_npub,

View file

@ -839,12 +839,21 @@ window.app = Vue.createApp({
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Pair / revoke spire (S0 / #9, #12) // Pair / revoke spire (S0 / #9, #12)
// ----------------------------------------------------------------- // -----------------------------------------------------------------
openPairDialog(machine) { async openPairDialog(machine) {
this.pairDialog.machine = machine this.pairDialog.machine = machine
this.pairDialog.relays = '' this.pairDialog.relays = ''
this.pairDialog.durationHours = null this.pairDialog.durationHours = null
this.pairDialog.result = null this.pairDialog.result = null
this.pairDialog.show = true 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() { async submitPair() {
@ -852,14 +861,12 @@ window.app = Vue.createApp({
.split(/[\s,]+/) .split(/[\s,]+/)
.map(s => s.trim()) .map(s => s.trim())
.filter(Boolean) .filter(Boolean)
if (!relays.length) { // relays is optional — blank falls back to the transport relay
Quasar.Notify.create({ // server-side (the pre-filled default), so no client-side requirement.
type: 'negative', const body = {}
message: 'At least one relay is required' if (relays.length) {
}) body.relays = relays
return
} }
const body = {relays}
if (this.pairDialog.durationHours) { if (this.pairDialog.durationHours) {
body.duration_hours = Number(this.pairDialog.durationHours) body.duration_hours = Number(this.pairDialog.durationHours)
} }

View file

@ -868,7 +868,7 @@
<q-input <q-input
v-model="pairDialog.relays" v-model="pairDialog.relays"
label="Relay(s) for the spire's events" label="Relay(s) for the spire's events"
hint="One per line. The same relay the spire publishes to (its VITE_RELAY_URL), e.g. wss://your-host/nostrrelay/<id>" 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."
type="textarea" autogrow type="textarea" autogrow
class="q-mb-md" class="q-mb-md"
dense outlined></q-input> dense outlined></q-input>

View file

@ -66,7 +66,9 @@ def _wire(monkeypatch, *, pair="ok"):
async def fake_owned(machine_id, user_id): async def fake_owned(machine_id, user_id):
return _machine() 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": if pair == "error":
raise PairingError("boom") raise PairingError("boom")
return _result() return _result()
@ -80,11 +82,18 @@ def _wire(monkeypatch, *, pair="ok"):
state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name) state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name)
return _machine(npub=machine_npub) 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, "_machine_owned_by", fake_owned)
monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin) monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin)
monkeypatch.setattr(views_api, "pair_spire", fake_pair) monkeypatch.setattr(views_api, "pair_spire", fake_pair)
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_collision) monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_collision)
monkeypatch.setattr(views_api, "set_machine_pairing", fake_persist) monkeypatch.setattr(views_api, "set_machine_pairing", fake_persist)
monkeypatch.setattr(views_api, "get_super_config", fake_super_config)
return state return state
@ -104,11 +113,12 @@ def test_pair_persists_hex_npub_and_returns_seed(monkeypatch):
assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1") 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
# transport relay (and validates), so the endpoint passes them through.
_wire(monkeypatch) _wire(monkeypatch)
with pytest.raises(HTTPException) as ei: result = _call([])
_call([]) assert result.seed_url == "spire-seed:v1:abc"
assert ei.value.status_code == 400
def test_pair_failure_maps_to_bad_gateway(monkeypatch): def test_pair_failure_maps_to_bad_gateway(monkeypatch):
@ -165,3 +175,18 @@ def test_revoke_failure_maps_to_bad_gateway(monkeypatch):
_call_revoke() _call_revoke()
assert ei.value.status_code == 502 assert ei.value.status_code == 502
assert state["unpaired"] is None # not persisted on failure 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,6 +17,7 @@ from datetime import datetime, timezone
import pytest import pytest
from lnbits.core.services.nsec_bunker import NsecBunkerError from lnbits.core.services.nsec_bunker import NsecBunkerError
from lnbits.settings import settings
from lnbits.utils.nostr import hex_to_npub from lnbits.utils.nostr import hex_to_npub
from ..models import Machine from ..models import Machine
@ -27,19 +28,43 @@ from ..pairing import (
SPIRE_POLICY_RULES, SPIRE_POLICY_RULES,
PairingError, PairingError,
build_seed_url, build_seed_url,
default_relay_endpoint,
pair_spire, pair_spire,
revoke_spire, revoke_spire,
spire_key_name, 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) _NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" _SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX) _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"] _RELAYS = ["wss://lnbits.demo.aiolabs.dev/nostrrelay/demo"]
_BUNKER_RELAY = "wss://bunker.internal/relay" _BUNKER_RELAY = "wss://bunker.internal/relay"
_PASSPHRASE = "keystore-pass" # pragma: allowlist secret _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) @pytest.fixture(autouse=True)
def _clear_policy_cache(): def _clear_policy_cache():
# lnbits' ensure_policy caches resolved policy ids on # lnbits' ensure_policy caches resolved policy ids on
@ -157,18 +182,28 @@ def test_bunker_url_carries_pubkey_relay_secret():
assert "secret=topsecret" in result.bunker_url 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(): 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 result = _pair(FakeBunker(token_secret="zzz")) # pragma: allowlist secret
assert result.seed_url.startswith(SEED_URL_SCHEME) payload = _decode_seed(result.seed_url)
blob = result.seed_url[len(SEED_URL_SCHEME) :]
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
assert payload == { assert payload == {
"v": 1, "v": 1,
"spire_npub": _SPIRE_NPUB, "spire_npub": _SPIRE_NPUB,
"spire_pubkey": _SPIRE_HEX, "lnbits_npub": _LNBITS_NPUB,
"bunker_url": result.bunker_url, "bunker_secret": "zzz",
"relays": _RELAYS, "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(): def test_fresh_policy_adds_kindless_nip44_rules():
@ -218,6 +253,99 @@ def test_malformed_token_raises():
_pair(bunker) _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(): def test_bunker_relay_defaults_to_spire_event_relay():
"""No explicit bunker_relay -> the relay baked into bunker_url is the spire's """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 own public event relay (relays[0]), NOT lnbits's internal bunker URL. This
@ -240,7 +368,7 @@ def test_bunker_relay_defaults_to_spire_event_relay():
assert "127.0.0.1" not in result.bunker_url 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"): with pytest.raises(PairingError, match="PASSPHRASE"):
asyncio.run( asyncio.run(
pair_spire( pair_spire(
@ -251,13 +379,80 @@ def test_missing_relay_or_passphrase_raises():
keystore_passphrase="", keystore_passphrase="",
) )
) )
with pytest.raises(PairingError, match="relay is required"):
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):
asyncio.run( asyncio.run(
pair_spire( pair_spire(
_machine(), _machine(),
relays=[], relays=[bad_relay],
admin_client=FakeBunker(), admin_client=FakeBunker(),
bunker_relay=_BUNKER_RELAY,
keystore_passphrase=_PASSPHRASE, keystore_passphrase=_PASSPHRASE,
) )
) )
@ -266,14 +461,40 @@ def test_missing_relay_or_passphrase_raises():
def test_build_seed_url_roundtrip(): def test_build_seed_url_roundtrip():
url = build_seed_url( url = build_seed_url(
spire_npub=_SPIRE_NPUB, spire_npub=_SPIRE_NPUB,
spire_pubkey_hex=_SPIRE_HEX, lnbits_npub=_LNBITS_NPUB,
bunker_url="bunker://x?relay=r&secret=s", bunker_secret="s", # pragma: allowlist secret
relays=_RELAYS, relays=_RELAYS,
bunker_relay=_BUNKER_RELAY,
) )
blob = url[len(SEED_URL_SCHEME) :] payload = _decode_seed(url)
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) assert payload["spire_npub"] == _SPIRE_NPUB
assert payload["spire_pubkey"] == _SPIRE_HEX assert payload["lnbits_npub"] == _LNBITS_NPUB
assert payload["bunker_secret"] == "s"
assert payload["relays"] == _RELAYS 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(): def test_pair_threads_duration_hours():

View file

@ -33,6 +33,7 @@ from .pairing import (
PairResult, PairResult,
PairingError, PairingError,
RevokeResult, RevokeResult,
default_relay_endpoint,
pair_spire, pair_spire,
revoke_spire, revoke_spire,
) )
@ -297,6 +298,15 @@ async def api_create_machine(
return 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( @spirekeeper_api_router.post(
"/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult "/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult
) )
@ -315,8 +325,8 @@ async def api_pair_machine(
`duration_hours` (optional) time-bounds the token; revoke via the `duration_hours` (optional) time-bounds the token; revoke via the
sibling `POST .../revoke` endpoint.""" sibling `POST .../revoke` endpoint."""
machine = await _machine_owned_by(machine_id, user.id) machine = await _machine_owned_by(machine_id, user.id)
if not data.relays: # relays may be omitted — pair_spire defaults to the transport relay and
raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required") # validates reachability (raises PairingError → 502).
try: try:
async with NsecBunkerAdminClient.from_settings() as client: async with NsecBunkerAdminClient.from_settings() as client: