From 9c5f07c72e103a6f6c12b0f9498839b48c769fcf Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 18:31:21 +0200 Subject: [PATCH] refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pairing.py | 59 ++++++------------------------------------- tests/test_pairing.py | 13 ++++++++++ 2 files changed, 21 insertions(+), 51 deletions(-) diff --git a/pairing.py b/pairing.py index 6725a80..833de3a 100644 --- a/pairing.py +++ b/pairing.py @@ -14,7 +14,7 @@ reference for the admin chain): spirekeeper (here) spire, at first boot (bitspire#52) ────────────────── ────────────────────────────────── 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 4. get_key_tokens ─ seed ─► client_nsec stays on the spire; the (package token in URL) signing key never leaves the bunker. @@ -46,6 +46,7 @@ from lnbits.core.services.nsec_bunker import ( NsecBunkerNotConfiguredError, npub_to_hex, ) +from lnbits.core.signers.remote_bunker import ensure_policy from lnbits.settings import settings from pydantic import BaseModel @@ -103,55 +104,6 @@ def spire_key_name(machine_id: str) -> str: 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: """Pull the freshly-issued `#` token out of the bunker's `get_key_tokens` response. Match by client name when the bunker @@ -238,7 +190,12 @@ async def pair_spire( try: spire_npub = await admin_client.create_new_key(key_name, passphrase) 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) tokens = await admin_client.get_key_tokens(key_name) except NsecBunkerNotConfiguredError as exc: diff --git a/tests/test_pairing.py b/tests/test_pairing.py index bda1402..82eb4a2 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -38,6 +38,17 @@ _BUNKER_RELAY = "wss://bunker.internal/relay" _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: return Machine( id=mid, @@ -56,6 +67,8 @@ def _machine(mid: str = "m1") -> Machine: class FakeBunker: """Records calls; returns canned bunker responses.""" + admin_pubkey = "fake-admin-pubkey" + # pragma: allowlist secret def __init__(self, *, policies=None, token_secret="s3cr3t"): self._policies = policies or []