feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)
Some checks failed
ci.yml / feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22) (pull_request) Failing after 0s

Builds on the seed-URL pairing in #21 (stacked).

(b) TTL — PairMachineData.duration_hours (validated > 0) threads through
    pair_spire -> create_new_token (lnbits#55). None = non-expiring.

(c) Revoke — POST /machines/{id}/revoke -> revoke_spire ->
    admin_client.revoke_key_user(spire-<id>). Per spirekeeper#22, revoke
    MUST go through KeyUser.revokedAt (revoke_key_user), NOT token revoke:
    lnbits eager-binds (redeems) the connect token at provision, so
    nsecbunkerd has materialised the policy into per-KeyUser grants its
    ACL checks BEFORE the Token.revokedAt filter -> token revoke is a
    silent no-op. Returns RevokeResult{revoked_count}: >=1 = cut, 0 =
    never bound. set_machine_unpaired clears paired_at (keeps npub +
    bunker_spire_key_name for audit / re-pair).

7 new tests (duration threading + default-None; revoke routes to
revoke_key_user and never token-revoke + error mapping; endpoint wiring
revoke happy/zero/502). 210 green; new code black/ruff-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-18 18:51:54 +02:00
commit a5efdf22a1
6 changed files with 223 additions and 10 deletions

View file

@ -29,7 +29,13 @@ from .cassette_transport import (
publish_to_atm,
)
from .fee_transport import publish_fee_config
from .pairing import PairResult, PairingError, pair_spire
from .pairing import (
PairResult,
PairingError,
RevokeResult,
pair_spire,
revoke_spire,
)
from .crud import (
append_settlement_note,
count_completed_legs_for_settlement,
@ -63,6 +69,7 @@ from .crud import (
replace_commission_splits,
reset_settlement_for_retry,
set_machine_pairing,
set_machine_unpaired,
update_cassette_config,
update_dca_client,
update_deposit,
@ -297,15 +304,21 @@ async def api_pair_machine(
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)."""
Re-pair is supported (re-issues a token for the same spire key).
`duration_hours` (optional) time-bounds the token; revoke via the
sibling `POST .../revoke` endpoint."""
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)
result = await pair_spire(
machine,
relays=data.relays,
admin_client=client,
duration_hours=data.duration_hours,
)
except NsecBunkerNotConfiguredError as exc:
raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE,
@ -327,6 +340,34 @@ async def api_pair_machine(
return result
@spirekeeper_api_router.post(
"/api/v1/dca/machines/{machine_id}/revoke", response_model=RevokeResult
)
async def api_revoke_machine(
machine_id: str,
user: User = Depends(check_user_exists),
) -> RevokeResult:
"""Revoke a spire's bunker access — the "Revoke spire access" UX
(#9/#12). Cuts the spire's signing ability at the bunker
(`KeyUser.revokedAt` via `revoke_key_user`; token-revoke alone is a
no-op once the token is redeemed see #22), then marks the machine
unpaired. `revoked_count` >= 1 = access cut; 0 = nothing was bound."""
machine = await _machine_owned_by(machine_id, user.id)
try:
async with NsecBunkerAdminClient.from_settings() as client:
revoked_count = await revoke_spire(machine, 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"revoke failed: {exc}") from exc
await set_machine_unpaired(machine_id)
return RevokeResult(revoked_count=revoked_count)
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
async def api_list_machines(
user: User = Depends(check_user_exists),