Compare commits
No commits in common. "8b64e9c7425ab7da2bdc992830104c0d162a35f1" and "a5ab02e4b6b500522e020840c791c518c4a91eb1" have entirely different histories.
8b64e9c742
...
a5ab02e4b6
7 changed files with 67 additions and 474 deletions
|
|
@ -94,12 +94,10 @@ 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.
|
||||||
`relays` is optional: when omitted it defaults to the relay the transport
|
`duration_hours` optionally time-bounds the spire's connect token
|
||||||
listens on (derived from the transport config), so the operator needn't
|
(None = non-expiring)."""
|
||||||
supply one (bitspire#70). `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
|
bunker_relay: str | None = None
|
||||||
duration_hours: int | None = None
|
duration_hours: int | None = None
|
||||||
|
|
||||||
|
|
|
||||||
210
pairing.py
210
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
|
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, slimmed in bitspire#70):
|
Seed URL wire format (contract shared with bitspire#52):
|
||||||
|
|
||||||
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
|
||||||
"lnbits_npub": "npub1…", # this lnbits' nostr-transport server id
|
"spire_pubkey": "<64-hex>", # same key, hex (consumer convenience)
|
||||||
"bunker_secret": "<sec>", # one-shot NIP-46 connect token
|
"bunker_url": "bunker://<spire_pubkey>?relay=<bunker_relay>&secret=<sec>",
|
||||||
"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
|
||||||
import re
|
from urllib.parse import quote
|
||||||
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
|
||||||
|
|
@ -145,51 +135,16 @@ 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:
|
||||||
"""Build the slim seed URL (bitspire#70). The pubkey rides once as
|
payload = {
|
||||||
`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,
|
||||||
"lnbits_npub": lnbits_npub,
|
"spire_pubkey": spire_pubkey_hex,
|
||||||
"bunker_secret": bunker_secret,
|
"bunker_url": bunker_url,
|
||||||
"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()
|
||||||
|
|
@ -198,67 +153,10 @@ 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] | None = None,
|
relays: list[str],
|
||||||
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,
|
||||||
|
|
@ -282,14 +180,10 @@ 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). When omitted, they default to the relay the transport
|
(kind-21000/30078) — typically the operator's public nostrrelay; supplied by
|
||||||
listens on (`default_relay_endpoint`, from `nostr_transport_relays` re-homed
|
the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the
|
||||||
on `lnbits_baseurl`) so a seed points at a relay that actually acks EVENTs.
|
spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase`
|
||||||
Every relay (and `bunker_relay`) is validated as a remote-reachable
|
defaults to the lnbits bunker setting. Both injectable for tests.
|
||||||
`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).
|
||||||
|
|
@ -304,53 +198,33 @@ 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:
|
||||||
endpoint = default_relay_endpoint()
|
raise PairingError("at least one relay is required for the seed URL")
|
||||||
if not endpoint:
|
# The relay baked into `bunker_url` is where the *spire* (the remote ATM)
|
||||||
raise PairingError(
|
# reaches the bunker, so it must be a machine-reachable public URL — NOT
|
||||||
"no relays given and none could be derived — set "
|
# `settings.lnbits_nsec_bunker_url`, which is how the co-located lnbits
|
||||||
"nostr_transport_relays (and lnbits_baseurl if it's a loopback relay)"
|
# 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
|
||||||
relays = [endpoint]
|
# own event relay (the bunker lives on the same operator relay the spire
|
||||||
|
# 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:
|
||||||
# Each admin RPC is retried past a transient bunker timeout (#38);
|
spire_npub = await admin_client.create_new_key(key_name, passphrase)
|
||||||
# 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 _bunker_retry(
|
policy_id = await ensure_policy(
|
||||||
lambda: ensure_policy(
|
admin_client,
|
||||||
admin_client,
|
name=SPIRE_POLICY_NAME,
|
||||||
name=SPIRE_POLICY_NAME,
|
rules=SPIRE_POLICY_RULES,
|
||||||
rules=SPIRE_POLICY_RULES,
|
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
|
||||||
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
await _bunker_retry(
|
await admin_client.create_new_token(
|
||||||
lambda: admin_client.create_new_token(
|
key_name, client_name, policy_id, duration_hours=duration_hours
|
||||||
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 admin_client.get_key_tokens(key_name)
|
||||||
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:
|
||||||
|
|
@ -359,31 +233,15 @@ 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,
|
||||||
lnbits_npub=lnbits_npub,
|
spire_pubkey_hex=spire_pubkey_hex,
|
||||||
bunker_secret=secret,
|
bunker_url=bunker_url,
|
||||||
relays=relays,
|
relays=relays,
|
||||||
bunker_relay=relay,
|
|
||||||
)
|
)
|
||||||
return PairResult(
|
return PairResult(
|
||||||
spire_npub=spire_npub,
|
spire_npub=spire_npub,
|
||||||
|
|
|
||||||
|
|
@ -839,21 +839,12 @@ window.app = Vue.createApp({
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Pair / revoke spire (S0 / #9, #12)
|
// Pair / revoke spire (S0 / #9, #12)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
async openPairDialog(machine) {
|
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() {
|
||||||
|
|
@ -861,12 +852,14 @@ window.app = Vue.createApp({
|
||||||
.split(/[\s,]+/)
|
.split(/[\s,]+/)
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
// relays is optional — blank falls back to the transport relay
|
if (!relays.length) {
|
||||||
// server-side (the pre-filled default), so no client-side requirement.
|
Quasar.Notify.create({
|
||||||
const body = {}
|
type: 'negative',
|
||||||
if (relays.length) {
|
message: 'At least one relay is required'
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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="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
|
type="textarea" autogrow
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
dense outlined></q-input>
|
dense outlined></q-input>
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,7 @@ 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(
|
async def fake_pair(machine, *, relays, admin_client, duration_hours=None):
|
||||||
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()
|
||||||
|
|
@ -82,18 +80,11 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -113,12 +104,11 @@ 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_accepted(monkeypatch):
|
def test_pair_empty_relays_rejected(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)
|
||||||
result = _call([])
|
with pytest.raises(HTTPException) as ei:
|
||||||
assert result.seed_url == "spire-seed:v1:abc"
|
_call([])
|
||||||
|
assert ei.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_pair_failure_maps_to_bad_gateway(monkeypatch):
|
def test_pair_failure_maps_to_bad_gateway(monkeypatch):
|
||||||
|
|
@ -175,18 +165,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ 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
|
||||||
|
|
@ -28,43 +27,19 @@ 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
|
||||||
|
|
@ -182,28 +157,18 @@ 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
|
||||||
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 == {
|
assert payload == {
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"spire_npub": _SPIRE_NPUB,
|
"spire_npub": _SPIRE_NPUB,
|
||||||
"lnbits_npub": _LNBITS_NPUB,
|
"spire_pubkey": _SPIRE_HEX,
|
||||||
"bunker_secret": "zzz",
|
"bunker_url": result.bunker_url,
|
||||||
"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():
|
||||||
|
|
@ -253,99 +218,6 @@ 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
|
||||||
|
|
@ -368,7 +240,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_passphrase_raises():
|
def test_missing_relay_or_passphrase_raises():
|
||||||
with pytest.raises(PairingError, match="PASSPHRASE"):
|
with pytest.raises(PairingError, match="PASSPHRASE"):
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
pair_spire(
|
pair_spire(
|
||||||
|
|
@ -379,80 +251,13 @@ def test_missing_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=[bad_relay],
|
relays=[],
|
||||||
admin_client=FakeBunker(),
|
admin_client=FakeBunker(),
|
||||||
|
bunker_relay=_BUNKER_RELAY,
|
||||||
keystore_passphrase=_PASSPHRASE,
|
keystore_passphrase=_PASSPHRASE,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -461,40 +266,14 @@ def test_rejects_bad_relay(bad_relay, match):
|
||||||
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,
|
||||||
lnbits_npub=_LNBITS_NPUB,
|
spire_pubkey_hex=_SPIRE_HEX,
|
||||||
bunker_secret="s", # pragma: allowlist secret
|
bunker_url="bunker://x?relay=r&secret=s",
|
||||||
relays=_RELAYS,
|
relays=_RELAYS,
|
||||||
bunker_relay=_BUNKER_RELAY,
|
|
||||||
)
|
)
|
||||||
payload = _decode_seed(url)
|
blob = url[len(SEED_URL_SCHEME) :]
|
||||||
assert payload["spire_npub"] == _SPIRE_NPUB
|
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
|
||||||
assert payload["lnbits_npub"] == _LNBITS_NPUB
|
assert payload["spire_pubkey"] == _SPIRE_HEX
|
||||||
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():
|
||||||
|
|
|
||||||
14
views_api.py
14
views_api.py
|
|
@ -33,7 +33,6 @@ from .pairing import (
|
||||||
PairResult,
|
PairResult,
|
||||||
PairingError,
|
PairingError,
|
||||||
RevokeResult,
|
RevokeResult,
|
||||||
default_relay_endpoint,
|
|
||||||
pair_spire,
|
pair_spire,
|
||||||
revoke_spire,
|
revoke_spire,
|
||||||
)
|
)
|
||||||
|
|
@ -298,15 +297,6 @@ 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
|
||||||
)
|
)
|
||||||
|
|
@ -325,8 +315,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)
|
||||||
# relays may be omitted — pair_spire defaults to the transport relay and
|
if not data.relays:
|
||||||
# validates reachability (raises PairingError → 502).
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with NsecBunkerAdminClient.from_settings() as client:
|
async with NsecBunkerAdminClient.from_settings() as client:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue