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
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:
parent
a77f5bcb5c
commit
761f078053
4 changed files with 213 additions and 0 deletions
31
crud.py
31
crud.py
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
120
tests/test_pair_endpoint.py
Normal 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
|
||||||
53
views_api.py
53
views_api.py
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue