feat(v2): route nostr_publish signing through lnbits#17 signer abstraction (hybrid)

Responds to the lnbits session's 19:30Z coordination-log flag: PR #17
will NULL `accounts.prvkey` on cascade via the m002 classify job, which
would break the S4 fleet-roster publishing path (`131ff92`) — it reads
`account.prvkey` directly.

Hybrid migration in `_sign_as_operator`:

  1. Try `from lnbits.core.signers import resolve_signer` — post-#17
     lnbits provides this; routes through the per-account signer that
     understands LocalSigner (envelope-encrypted nsec at rest),
     ClientSideOnlySigner (server can't sign — soft-fail), and the
     future RemoteBunkerSigner (lnbits#18; phase 2).
  2. On ImportError, fall through to the direct `account.prvkey` read
     identical to the pre-#17 implementation. Same wire-level signed
     event either way; the fallback exists only to avoid a hard
     ordering dependency between this commit and the lnbits #17
     cascade landing on the host.

Soft-failure surfaces (all log + skip, don't break machine CRUD):
  - operator has no pubkey on file → skip.
  - signer resolve fails (unclassified account, etc.) → skip.
  - `signer.can_sign()` False (ClientSideOnlySigner) → skip.
  - `SignerUnavailableError` raised at sign time → skip.

Why hybrid instead of waiting for #17 to land first: pre-#17 lnbits is
what's currently in production / dev. If we ship a hard `from
lnbits.core.signers import ...` now, satmachineadmin breaks at import
time on every host running the current nostr-transport branch. The
try/except guard is the same shape lnbits core uses for cross-extension
imports (nostrmarket / nostrrelay).

Sister migrations on other extensions (nostrmarket, restaurant, tasks,
events) are tracked at `aiolabs/lnbits#21` umbrella + per-extension
issues that the lnbits session filed in the 2026-05-26T20:00Z audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-26 22:24:29 +02:00
commit e13178d3ac

View file

@ -79,33 +79,94 @@ async def _publish_signed_event(signed_event: dict) -> None:
async def _sign_as_operator(
operator_user_id: str, event: dict
) -> Optional[dict]:
"""Sign `event` using the operator's stored Nostr nsec.
"""Sign `event` on behalf of the operator.
Mutates `event` to add `created_at` (now), `pubkey`, `id`, and `sig`.
Returns the signed event; returns None (with a warning log) if the
operator account doesn't have a pubkey + nsec pair on file — covers
operator account doesn't have an available signer — covers
(a) accounts created via non-Nostr login that never set up identity,
(b) post-bunker future (lnbits#18) where the nsec is moved off-disk
and the bunker client isn't yet wired through here,
(c) misconfiguration.
(b) accounts where the server has only the pubkey
(`ClientSideOnlySigner`),
(c) post-bunker future (lnbits#18) where signing routes through a
NIP-46 bunker that isn't reachable.
Soft-failure is the right behaviour publishing kind:30078 is a
side-effect of CRUD, not a precondition for it. The machine row
still gets written; only the public-facing event is skipped.
side-effect of machine CRUD, not a precondition for it. The machine
row still gets written; only the public-facing event is skipped.
Routing: post-`aiolabs/lnbits#17` (signer abstraction) we go through
`lnbits.core.signers.resolve_signer`, which transparently handles
`LocalSigner` (envelope-encrypted nsec at rest, decrypted on demand)
and `ClientSideOnlySigner` (raises `SignerUnavailableError` we
treat as soft-fail). On pre-#17 lnbits versions the import fails and
we fall back to a direct `account.prvkey` read so this code keeps
working during the #17 cascade rollout window. Both paths produce
identical signed events; the hybrid avoids a hard ordering
dependency between this extension and the lnbits #17 PR landing.
"""
account = await get_account(operator_user_id)
if account is None or not account.pubkey or not account.prvkey:
if account is None or not account.pubkey:
logger.warning(
f"satmachineadmin: operator {operator_user_id[:8]}... has no "
f"Nostr keypair on file; skipping kind:{event['kind']} publish. "
"Onboard via the LNbits Nostr-login flow, or wait for "
"aiolabs/lnbits#18 bunker integration."
f"Nostr pubkey on file; skipping kind:{event['kind']} publish. "
"Onboard via the LNbits Nostr-login flow."
)
return None
# `created_at` is part of the BIP-340 event-id hash; must be set
# before signing so both code paths below see the same value.
event["created_at"] = int(time.time())
event["pubkey"] = account.pubkey
private_key = coincurve.PrivateKey(bytes.fromhex(account.prvkey))
return sign_event(event, account.pubkey, private_key)
try:
from lnbits.core.signers import ( # type: ignore[import-not-found]
SignerError,
SignerUnavailableError,
resolve_signer,
)
except ImportError:
# Pre-#17 lnbits — direct prvkey read. Identical to the
# original implementation; the abstraction takes over once
# #17 cascades to this host.
if not account.prvkey:
logger.warning(
f"satmachineadmin: operator {operator_user_id[:8]}... has "
f"no signing key on file; skipping kind:{event['kind']} "
f"publish. Onboard via the LNbits Nostr-login flow, or "
f"wait for aiolabs/lnbits#18 bunker integration."
)
return None
private_key = coincurve.PrivateKey(bytes.fromhex(account.prvkey))
return sign_event(event, account.pubkey, private_key)
# Post-#17 lnbits — route through the signer abstraction.
try:
signer = resolve_signer(account)
except SignerError as exc:
logger.warning(
f"satmachineadmin: signer resolve failed for operator "
f"{operator_user_id[:8]}...: {exc}. Skipping kind:"
f"{event['kind']} publish."
)
return None
if not signer.can_sign():
logger.warning(
f"satmachineadmin: operator {operator_user_id[:8]}... has a "
f"client-side-only signer; server can't publish kind:"
f"{event['kind']} on their behalf. Wait for bunker "
f"integration (lnbits#18) or operator-driven publishing."
)
return None
try:
return signer.sign_event(event)
except SignerUnavailableError as exc:
logger.warning(
f"satmachineadmin: signer unavailable for operator "
f"{operator_user_id[:8]}...: {exc}. Skipping kind:"
f"{event['kind']} publish."
)
return None
async def publish_machine_config(machine: Machine) -> None: