feat(pairing): POST /machines/{id}/pair endpoint (#9)
Some checks failed
ci.yml / feat(pairing): POST /machines/{id}/pair endpoint (#9) (pull_request) Failing after 0s

Wires the pairing service into the operator API. api_pair_machine:
  - _machine_owned_by ownership guard (404 on miss)
  - opens NsecBunkerAdminClient.from_settings() and runs pair_spire
  - maps bunker failures: not-configured -> 503, PairingError/NsecBunkerError
    -> 502 (nothing persisted on failure)
  - runs _assert_no_pubkey_collision on the bunker-minted hex, then
    set_machine_pairing persists machine_npub (= minted spire identity, so
    path-B roster routes it), bunker_spire_key_name, paired_at.

Re-pair supported; revoke/expiry gated on aiolabs/lnbits#54.

Adds Create... PairMachineData {relays} body, set_machine_pairing CRUD,
and 3 endpoint wiring tests (persist+collision, empty-relays 400, failure
502). 203 tests green. Pre-existing black/ruff debt in crud/views_api left
untouched (version-drift churn avoided); new code is lint-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-16 23:39:18 +02:00
commit 761f078053
4 changed files with 213 additions and 0 deletions

31
crud.py
View file

@ -202,6 +202,37 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine |
return await get_machine(machine_id) return await get_machine(machine_id)
async def set_machine_pairing(
machine_id: str,
*,
machine_npub: str,
bunker_spire_key_name: str,
paired_at: datetime,
) -> Machine | None:
"""Persist the result of a (re-)pair: the bunker-minted spire identity
becomes the machine's npub (so lnbits' path-B roster routes it), and we
record the bunker key name + pair time. Stored as lowercase hex the
roster + collision guard normalise either form, hex is canonical."""
await db.execute(
"""
UPDATE spirekeeper.dca_machines
SET machine_npub = :npub,
bunker_spire_key_name = :key_name,
paired_at = :paired_at,
updated_at = :updated_at
WHERE id = :id
""",
{
"npub": machine_npub.lower(),
"key_name": bunker_spire_key_name,
"paired_at": paired_at,
"updated_at": datetime.now(),
"id": machine_id,
},
)
return await get_machine(machine_id)
async def delete_machine(machine_id: str) -> None: async def delete_machine(machine_id: str) -> None:
await db.execute( await db.execute(
"DELETE FROM spirekeeper.dca_machines WHERE id = :id", "DELETE FROM spirekeeper.dca_machines WHERE id = :id",

View file

@ -81,6 +81,15 @@ class UpdateMachineData(BaseModel):
return round(float(v), 4) return round(float(v), 4)
class PairMachineData(BaseModel):
"""Body for POST /machines/{id}/pair (S0 / #9). `relays` are the relays
the spire will use for its own events (kind-21000/30078) typically the
operator's nostrrelay; the bunker connection relay is added separately
from the lnbits bunker settings."""
relays: list[str]
# ============================================================================= # =============================================================================
# DCA Clients — LP registrations, scoped per (machine, user). # DCA Clients — LP registrations, scoped per (machine, user).
# ============================================================================= # =============================================================================

120
tests/test_pair_endpoint.py Normal file
View file

@ -0,0 +1,120 @@
"""Wiring tests for POST /machines/{id}/pair (S0 / #9).
The pairing *service* is covered in test_pairing.py with a fake bunker;
here we only exercise the endpoint glue ownership, the empty-relays
guard, the post-mint collision guard, persistence of the bunker-minted
hex npub, and error mapping by monkeypatching the module-level deps.
"""
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from lnbits.utils.nostr import hex_to_npub
from .. import views_api
from ..models import Machine, PairMachineData
from ..pairing import PairingError, PairResult
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX)
def _machine(npub: str = "placeholder") -> Machine:
return Machine(
id="m1",
operator_user_id="op1",
machine_npub=npub,
wallet_id="w1",
name="sintra",
location=None,
fiat_code="EUR",
is_active=True,
created_at=_NOW,
updated_at=_NOW,
)
class _FakeAdmin:
@classmethod
def from_settings(cls):
return cls()
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def _result() -> PairResult:
return PairResult(
spire_npub=_SPIRE_NPUB,
spire_pubkey_hex=_SPIRE_HEX,
bunker_key_name="spire-m1",
bunker_url="bunker://x?relay=r&secret=s", # pragma: allowlist secret
seed_url="spire-seed:v1:abc",
)
def _wire(monkeypatch, *, pair="ok"):
state: dict = {"persisted": None, "collision": None}
async def fake_owned(machine_id, user_id):
return _machine()
async def fake_pair(machine, *, relays, admin_client):
if pair == "error":
raise PairingError("boom")
return _result()
async def fake_collision(npub):
state["collision"] = npub
async def fake_persist(
machine_id, *, machine_npub, bunker_spire_key_name, paired_at
):
state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name)
return _machine(npub=machine_npub)
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)
return state
def _call(relays):
user = SimpleNamespace(id="op1")
return asyncio.run(
views_api.api_pair_machine("m1", PairMachineData(relays=relays), user)
)
def test_pair_persists_hex_npub_and_returns_seed(monkeypatch):
state = _wire(monkeypatch)
result = _call(["wss://r"])
assert result.seed_url == "spire-seed:v1:abc"
# collision guard ran on the bunker-minted hex, and we persisted it as npub
assert state["collision"] == _SPIRE_HEX
assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1")
def test_pair_empty_relays_rejected(monkeypatch):
_wire(monkeypatch)
with pytest.raises(HTTPException) as ei:
_call([])
assert ei.value.status_code == 400
def test_pair_failure_maps_to_bad_gateway(monkeypatch):
state = _wire(monkeypatch, pair="error")
with pytest.raises(HTTPException) as ei:
_call(["wss://r"])
assert ei.value.status_code == 502
# nothing persisted on failure
assert state["persisted"] is None

View file

@ -5,12 +5,18 @@
# LNbits instance can never see each other's machines, settlements, or # LNbits instance can never see each other's machines, settlements, or
# clients. The super-only platform-fee write endpoint lands in P2. # clients. The super-only platform-fee write endpoint lands in P2.
from datetime import datetime, timezone
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
from lnbits.core.crud.users import get_account_by_pubkey from lnbits.core.crud.users import get_account_by_pubkey
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.core.services.nsec_bunker import (
NsecBunkerAdminClient,
NsecBunkerError,
NsecBunkerNotConfiguredError,
)
from lnbits.decorators import check_super_user, check_user_exists from lnbits.decorators import check_super_user, check_user_exists
from lnbits.utils.nostr import normalize_public_key from lnbits.utils.nostr import normalize_public_key
@ -23,6 +29,7 @@ from .cassette_transport import (
publish_to_atm, publish_to_atm,
) )
from .fee_transport import publish_fee_config from .fee_transport import publish_fee_config
from .pairing import PairResult, PairingError, pair_spire
from .crud import ( from .crud import (
append_settlement_note, append_settlement_note,
count_completed_legs_for_settlement, count_completed_legs_for_settlement,
@ -55,6 +62,7 @@ from .crud import (
lp_is_onboarded, lp_is_onboarded,
replace_commission_splits, replace_commission_splits,
reset_settlement_for_retry, reset_settlement_for_retry,
set_machine_pairing,
update_cassette_config, update_cassette_config,
update_dca_client, update_dca_client,
update_deposit, update_deposit,
@ -80,6 +88,7 @@ from .models import (
DcaPayment, DcaPayment,
DcaSettlement, DcaSettlement,
Machine, Machine,
PairMachineData,
PartialDispenseData, PartialDispenseData,
PublishCassettesPayload, PublishCassettesPayload,
SetCommissionSplitsData, SetCommissionSplitsData,
@ -274,6 +283,50 @@ async def api_create_machine(
return machine return machine
@spirekeeper_api_router.post(
"/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult
)
async def api_pair_machine(
machine_id: str,
data: PairMachineData,
user: User = Depends(check_user_exists),
) -> PairResult:
"""Seed-URL pairing (S0 / #9, model A1). Mints a per-spire signing key
inside the operator's nsecbunkerd and returns the one-shot seed URL the
spire redeems at first boot. The bunker-minted key becomes the machine's
npub, so lnbits' path-B roster routes the spire's cash-out RPCs to this
operator's wallet — no nsec ever lands on the spire.
Re-pair is supported (re-issues a token for the same spire key). Token
revocation + expiry are gated on aiolabs/lnbits#54 (admin-client gaps)."""
machine = await _machine_owned_by(machine_id, user.id)
if not data.relays:
raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required")
try:
async with NsecBunkerAdminClient.from_settings() as client:
result = await pair_spire(machine, relays=data.relays, admin_client=client)
except NsecBunkerNotConfiguredError as exc:
raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE,
f"nsecbunkerd is not configured on this LNbits instance: {exc}",
) from exc
except (PairingError, NsecBunkerError) as exc:
raise HTTPException(HTTPStatus.BAD_GATEWAY, f"pairing failed: {exc}") from exc
# The bunker-minted identity becomes the machine npub — run the same
# collision guard as create before persisting (fresh keys ~never collide,
# but defence-in-depth keeps the no-collision invariant intact).
await _assert_no_pubkey_collision(result.spire_pubkey_hex)
await set_machine_pairing(
machine_id,
machine_npub=result.spire_pubkey_hex,
bunker_spire_key_name=result.bunker_key_name,
paired_at=datetime.now(timezone.utc),
)
return result
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine]) @spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
async def api_list_machines( async def api_list_machines(
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),