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

18
crud.py
View file

@ -233,6 +233,24 @@ async def set_machine_pairing(
return await get_machine(machine_id)
async def set_machine_unpaired(machine_id: str) -> Machine | None:
"""Mark a machine unpaired after revoking its spire's bunker access
(POST /revoke). Clears `paired_at`; keeps `machine_npub` +
`bunker_spire_key_name` for audit / re-pair. The bunker-side
`KeyUser.revokedAt` (set by `revoke_spire`) is what actually stops the
spire signing this just records the operator-visible state."""
await db.execute(
"""
UPDATE spirekeeper.dca_machines
SET paired_at = NULL,
updated_at = :updated_at
WHERE id = :id
""",
{"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",