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
53
views_api.py
53
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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue