refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9)
Some checks failed
ci.yml / refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9) (pull_request) Failing after 0s

Adopts aiolabs/lnbits#55 (merged b5fba561): pair_spire now calls the
public ensure_policy(client, name='spirekeeper-spire', rules=...,
methods_no_kind=...) instead of spirekeeper's cache-free
_ensure_spire_policy copy. #55 re-keyed _POLICY_ID_CACHE on
(admin_pubkey, policy_name), so the shared helper no longer returns the
wrong (lnbits-default) id for a non-default policy name — the exact
reason the duplicate existed. Net -45 LOC, one less fork-divergent
reimplementation to keep in sync.

Requires lnbits >= the #55 merge (ensure_policy importable) — already
true on dev/demo.

Tests: FakeBunker gains admin_pubkey; an autouse fixture clears lnbits'
_POLICY_ID_CACHE between tests (the shared helper caches, unlike the old
local one). 203 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-18 18:31:21 +02:00
commit 9c5f07c72e
2 changed files with 21 additions and 51 deletions

View file

@ -14,7 +14,7 @@ reference for the admin chain):
spirekeeper (here) spire, at first boot (bitspire#52) spirekeeper (here) spire, at first boot (bitspire#52)
1. create_new_key 5. NIP-46 connect redeem the token with a 1. create_new_key 5. NIP-46 connect redeem the token with a
2. _ensure_spire_policy freshly-generated *client* keypair; bunker 2. ensure_policy freshly-generated *client* keypair; bunker
3. create_new_token binds (client_pubkey spire key). The 3. create_new_token binds (client_pubkey spire key). The
4. get_key_tokens seed client_nsec stays on the spire; the 4. get_key_tokens seed client_nsec stays on the spire; the
(package token in URL) signing key never leaves the bunker. (package token in URL) signing key never leaves the bunker.
@ -46,6 +46,7 @@ from lnbits.core.services.nsec_bunker import (
NsecBunkerNotConfiguredError, NsecBunkerNotConfiguredError,
npub_to_hex, npub_to_hex,
) )
from lnbits.core.signers.remote_bunker import ensure_policy
from lnbits.settings import settings from lnbits.settings import settings
from pydantic import BaseModel from pydantic import BaseModel
@ -103,55 +104,6 @@ def spire_key_name(machine_id: str) -> str:
return f"spire-{machine_id}" return f"spire-{machine_id}"
async def _ensure_spire_policy(client: NsecBunkerAdminClient) -> int:
"""Find-or-create the shared `spirekeeper-spire` policy and reconcile
(add-only) any missing rules. Returns its bunker-assigned id.
Mirrors lnbits' `_ensure_policy` but with the spire rule set and **no
admin-pubkey cache** lnbits' cache is keyed on `admin_pubkey` alone
(remote_bunker.py:157), so reusing its helper for a second policy name
would return the wrong (cached lnbits-default) id. Pairing is an
infrequent operator action, so the extra `get_policies` round-trip is
cheap and correct. (Filed as an lnbits follow-up: cache key should
include policy_name.)
Add-only, never remove: the policy may be shared across spires (and in
principle other consumers); removing a rule would affect them all.
"""
policies = await client.get_policies()
existing = [p for p in policies if p.get("name") == SPIRE_POLICY_NAME]
if existing:
policy = max(existing, key=lambda p: int(p["id"]))
policy_id = int(policy["id"])
live_rules = policy.get("rules") or []
else:
policy_id = await client.create_new_policy(
SPIRE_POLICY_NAME, SPIRE_POLICY_RULES
)
live_rules = list(SPIRE_POLICY_RULES)
def _norm(method: str, kind) -> tuple[str, str | None]:
# nsecbunkerd stores kind as a string; None = kind-less rule.
return (method, str(kind) if kind is not None else None)
live_keys = {
_norm(r["method"], r.get("kind"))
for r in live_rules
if isinstance(r, dict) and r.get("method")
}
for rule in SPIRE_POLICY_RULES:
key = _norm(rule["method"], rule.get("kind"))
if key not in live_keys:
await client.add_policy_rule(policy_id, rule)
live_keys.add(key)
for method in SPIRE_POLICY_METHODS_NO_KIND:
key = _norm(method, None)
if key not in live_keys:
await client.add_policy_rule(policy_id, {"method": method})
live_keys.add(key)
return policy_id
def _recover_token(tokens: list[dict], client_name: str) -> str: def _recover_token(tokens: list[dict], client_name: str) -> str:
"""Pull the freshly-issued `<npub>#<secret>` token out of the bunker's """Pull the freshly-issued `<npub>#<secret>` token out of the bunker's
`get_key_tokens` response. Match by client name when the bunker `get_key_tokens` response. Match by client name when the bunker
@ -238,7 +190,12 @@ async def pair_spire(
try: try:
spire_npub = await admin_client.create_new_key(key_name, passphrase) spire_npub = await admin_client.create_new_key(key_name, passphrase)
spire_pubkey_hex = npub_to_hex(spire_npub) spire_pubkey_hex = npub_to_hex(spire_npub)
policy_id = await _ensure_spire_policy(admin_client) policy_id = await ensure_policy(
admin_client,
name=SPIRE_POLICY_NAME,
rules=SPIRE_POLICY_RULES,
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
)
await admin_client.create_new_token(key_name, client_name, policy_id) await admin_client.create_new_token(key_name, client_name, policy_id)
tokens = await admin_client.get_key_tokens(key_name) tokens = await admin_client.get_key_tokens(key_name)
except NsecBunkerNotConfiguredError as exc: except NsecBunkerNotConfiguredError as exc:

View file

@ -38,6 +38,17 @@ _BUNKER_RELAY = "wss://bunker.internal/relay"
_PASSPHRASE = "keystore-pass" # pragma: allowlist secret _PASSPHRASE = "keystore-pass" # pragma: allowlist secret
@pytest.fixture(autouse=True)
def _clear_policy_cache():
# lnbits' ensure_policy caches resolved policy ids on
# (admin_pubkey, name); clear between tests so each FakeBunker's
# canned policy state is honoured rather than a stale cached id.
from lnbits.core.signers import remote_bunker
remote_bunker._POLICY_ID_CACHE.clear()
yield
def _machine(mid: str = "m1") -> Machine: def _machine(mid: str = "m1") -> Machine:
return Machine( return Machine(
id=mid, id=mid,
@ -56,6 +67,8 @@ def _machine(mid: str = "m1") -> Machine:
class FakeBunker: class FakeBunker:
"""Records calls; returns canned bunker responses.""" """Records calls; returns canned bunker responses."""
admin_pubkey = "fake-admin-pubkey"
# pragma: allowlist secret # pragma: allowlist secret
def __init__(self, *, policies=None, token_secret="s3cr3t"): def __init__(self, *, policies=None, token_secret="s3cr3t"):
self._policies = policies or [] self._policies = policies or []