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

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:
Padreug 2026-06-16 23:39:18 +02:00
commit 761f078053
4 changed files with 213 additions and 0 deletions

31
crud.py
View file

@ -202,6 +202,37 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine |
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:
await db.execute(
"DELETE FROM spirekeeper.dca_machines WHERE id = :id",