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
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:
parent
9c5f07c72e
commit
a5efdf22a1
6 changed files with 223 additions and 10 deletions
49
views_api.py
49
views_api.py
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue