From 761f0780537b3ec9194ec869ffdd2525c48dac93 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 23:39:18 +0200 Subject: [PATCH] feat(pairing): POST /machines/{id}/pair endpoint (#9) 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) --- crud.py | 31 ++++++++++ models.py | 9 +++ tests/test_pair_endpoint.py | 120 ++++++++++++++++++++++++++++++++++++ views_api.py | 53 ++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 tests/test_pair_endpoint.py diff --git a/crud.py b/crud.py index 9646f8d..bf51ecd 100644 --- a/crud.py +++ b/crud.py @@ -202,6 +202,37 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine | 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: await db.execute( "DELETE FROM spirekeeper.dca_machines WHERE id = :id", diff --git a/models.py b/models.py index 90df810..68ffdd2 100644 --- a/models.py +++ b/models.py @@ -81,6 +81,15 @@ class UpdateMachineData(BaseModel): 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). # ============================================================================= diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py new file mode 100644 index 0000000..d816c2f --- /dev/null +++ b/tests/test_pair_endpoint.py @@ -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 diff --git a/views_api.py b/views_api.py index 079794d..a37df74 100644 --- a/views_api.py +++ b/views_api.py @@ -5,12 +5,18 @@ # LNbits instance can never see each other's machines, settlements, or # clients. The super-only platform-fee write endpoint lands in P2. +from datetime import datetime, timezone from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException from lnbits.core.crud import get_wallet from lnbits.core.crud.users import get_account_by_pubkey 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.utils.nostr import normalize_public_key @@ -23,6 +29,7 @@ from .cassette_transport import ( publish_to_atm, ) from .fee_transport import publish_fee_config +from .pairing import PairResult, PairingError, pair_spire from .crud import ( append_settlement_note, count_completed_legs_for_settlement, @@ -55,6 +62,7 @@ from .crud import ( lp_is_onboarded, replace_commission_splits, reset_settlement_for_retry, + set_machine_pairing, update_cassette_config, update_dca_client, update_deposit, @@ -80,6 +88,7 @@ from .models import ( DcaPayment, DcaSettlement, Machine, + PairMachineData, PartialDispenseData, PublishCassettesPayload, SetCommissionSplitsData, @@ -274,6 +283,50 @@ async def api_create_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]) async def api_list_machines( user: User = Depends(check_user_exists),