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:
parent
131ff92aa8
commit
e13178d3ac
1 changed files with 75 additions and 14 deletions
|
|
@ -79,34 +79,95 @@ 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
|
||||
|
||||
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:
|
||||
"""Publish a per-machine kind:30078 config event signed by the operator.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue