Migrate Nostr publishing off account.prvkey → use resolve_signer (pre-cascade prerequisite for lnbits#17) #3

Open
opened 2026-05-26 18:37:58 +00:00 by padreug · 1 comment
Owner

Problem

nostr_hooks.py reads account.prvkey directly to sign task / completion / deletion events:

# nostr_hooks.py:18-29
"""Fetch (pubkey, prvkey) for the wallet's owning account..."""
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey:
    return None
return account.pubkey, account.prvkey

aiolabs/lnbits#17 (signer abstraction phase 1, PR open) ships an m002 classify job that fail-closed NULLs the accounts.prvkey column for every row at startup. When that PR cascades to a host running this extension, all account.prvkey reads return None and Nostr publishing silently stops.

Umbrella audit at aiolabs/lnbits#21 identifies this extension among 5 affected.

Sites to migrate

File:line Site
nostr_hooks.py:18-29 _resolve_keys helper (rename → _resolve_signer, return NostrSigner)
nostr_hooks.py:45-48 task-publish call: (pubkey, prvkey) = keys; publish_task_event(...)
nostr_hooks.py:70-73 completion-publish call
nostr_hooks.py:91-94 completion-delete call
nostr_publisher.py:141, 154, 171, 180, 196, … inner functions that accept account_prvkey: str and pass it to sign_nostr_event

Migration pattern

Replace the keypair helper with a signer-returning one:

from lnbits.core.signers import resolve_signer, NostrSigner
from lnbits.core.signers.base import SignerError

async def _resolve_signer(wallet_id: str) -> NostrSigner | None:
    wallet_obj = await get_wallet(wallet_id)
    if not wallet_obj:
        return None
    account = await get_account(wallet_obj.user)
    if not account or not account.pubkey:
        return None
    try:
        signer = resolve_signer(account)
    except SignerError as exc:
        logger.warning(f"[TASKS] cannot resolve signer for {account.id[:8]}: {exc}")
        return None
    if not signer.can_sign():
        logger.debug(f"[TASKS] account {account.id[:8]} cannot sign server-side; skipping publish")
        return None
    return signer

Threaded through nostr_publisher.py — each inner helper that takes account_prvkey: str switches to signer: NostrSigner and calls signer.sign_event(unsigned_dict) to populate the event's id + pubkey + sig.

# nostr_publisher.py — before:
def publish_task_event(nostr_client, task, account_pubkey, account_prvkey, delete=False):
    event = build_task_event(task, account_pubkey, delete=delete)
    sign_nostr_event(event, account_prvkey)
    nostr_client.publish_nostr_event(event)

# after:
def publish_task_event(nostr_client, task, signer: NostrSigner, delete=False):
    unsigned = build_task_event_unsigned(task, signer.pubkey, delete=delete)
    signed = signer.sign_event(unsigned)
    nostr_event = NostrEvent(**signed)  # or however your model wraps it
    nostr_client.publish_nostr_event(nostr_event)

Builders should produce an unsigned dict (kind / created_at / tags / content) and let the signer fill in id + pubkey + sig. The current sign_nostr_event extension-local helper becomes unnecessary.

Acceptance

  • _resolve_keys removed; _resolve_signer returns NostrSigner | None
  • account_prvkey parameter removed from all nostr_publisher.py functions; replaced with signer: NostrSigner
  • sign_nostr_event extension-local helper removed (signer handles it)
  • All three call sites in nostr_hooks.py updated (task publish, completion publish, completion delete)
  • Re-grep tasks/ post-migration: zero account.prvkey references
  • Manual smoke: create a task on a dev instance, verify Nostr event publishes
  • Bump pyproject.toml version to next vX.Y.Z-aio.N per the fork-versioning convention
  • Add catalog entry to aiolabs/lnbits-extensions/extensions.json (new entry alongside the previous)

Timing

Blocks aiolabs/lnbits#17's cascade to any host running the tasks extension. Currently no production host runs tasks; landing this before PR #17 cascades to dev-tier hosts is the conservative call.

Cross-references

  • aiolabs/lnbits#21 — umbrella audit (this is one of 5 affected extensions)
  • aiolabs/lnbits#17 — the cascading PR whose m002 NULLs accounts.prvkey
  • aiolabs/lnbits#9 — parent: operator-IdP framing, signer abstraction
  • aiolabs/restaurant#<TBD> — sister migration for the same pattern (filed in parallel)
  • aiolabs/events#<TBD> — sister migration
  • ~/dev/coordination/log.md 2026-05-26T19:30Z — cross-session coordination entry
## Problem `nostr_hooks.py` reads `account.prvkey` directly to sign task / completion / deletion events: ```python # nostr_hooks.py:18-29 """Fetch (pubkey, prvkey) for the wallet's owning account...""" account = await get_account(wallet_obj.user) if not account or not account.pubkey or not account.prvkey: return None return account.pubkey, account.prvkey ``` `aiolabs/lnbits#17` (signer abstraction phase 1, PR open) ships an `m002` classify job that **fail-closed NULLs the `accounts.prvkey` column** for every row at startup. When that PR cascades to a host running this extension, all `account.prvkey` reads return `None` and Nostr publishing silently stops. Umbrella audit at `aiolabs/lnbits#21` identifies this extension among 5 affected. ## Sites to migrate | File:line | Site | |---|---| | `nostr_hooks.py:18-29` | `_resolve_keys` helper (rename → `_resolve_signer`, return `NostrSigner`) | | `nostr_hooks.py:45-48` | task-publish call: `(pubkey, prvkey) = keys; publish_task_event(...)` | | `nostr_hooks.py:70-73` | completion-publish call | | `nostr_hooks.py:91-94` | completion-delete call | | `nostr_publisher.py:141, 154, 171, 180, 196, …` | inner functions that accept `account_prvkey: str` and pass it to `sign_nostr_event` | ## Migration pattern Replace the keypair helper with a signer-returning one: ```python from lnbits.core.signers import resolve_signer, NostrSigner from lnbits.core.signers.base import SignerError async def _resolve_signer(wallet_id: str) -> NostrSigner | None: wallet_obj = await get_wallet(wallet_id) if not wallet_obj: return None account = await get_account(wallet_obj.user) if not account or not account.pubkey: return None try: signer = resolve_signer(account) except SignerError as exc: logger.warning(f"[TASKS] cannot resolve signer for {account.id[:8]}: {exc}") return None if not signer.can_sign(): logger.debug(f"[TASKS] account {account.id[:8]} cannot sign server-side; skipping publish") return None return signer ``` Threaded through `nostr_publisher.py` — each inner helper that takes `account_prvkey: str` switches to `signer: NostrSigner` and calls `signer.sign_event(unsigned_dict)` to populate the event's `id` + `pubkey` + `sig`. ```python # nostr_publisher.py — before: def publish_task_event(nostr_client, task, account_pubkey, account_prvkey, delete=False): event = build_task_event(task, account_pubkey, delete=delete) sign_nostr_event(event, account_prvkey) nostr_client.publish_nostr_event(event) # after: def publish_task_event(nostr_client, task, signer: NostrSigner, delete=False): unsigned = build_task_event_unsigned(task, signer.pubkey, delete=delete) signed = signer.sign_event(unsigned) nostr_event = NostrEvent(**signed) # or however your model wraps it nostr_client.publish_nostr_event(nostr_event) ``` Builders should produce an unsigned dict (kind / created_at / tags / content) and let the signer fill in `id` + `pubkey` + `sig`. The current `sign_nostr_event` extension-local helper becomes unnecessary. ## Acceptance - [ ] `_resolve_keys` removed; `_resolve_signer` returns `NostrSigner | None` - [ ] `account_prvkey` parameter removed from all `nostr_publisher.py` functions; replaced with `signer: NostrSigner` - [ ] `sign_nostr_event` extension-local helper removed (signer handles it) - [ ] All three call sites in `nostr_hooks.py` updated (task publish, completion publish, completion delete) - [ ] Re-grep `tasks/` post-migration: zero `account.prvkey` references - [ ] Manual smoke: create a task on a dev instance, verify Nostr event publishes - [ ] Bump `pyproject.toml` version to next `vX.Y.Z-aio.N` per the fork-versioning convention - [ ] Add catalog entry to `aiolabs/lnbits-extensions/extensions.json` (new entry alongside the previous) ## Timing Blocks `aiolabs/lnbits#17`'s cascade to any host running the tasks extension. Currently no production host runs tasks; landing this before PR #17 cascades to dev-tier hosts is the conservative call. ## Cross-references - `aiolabs/lnbits#21` — umbrella audit (this is one of 5 affected extensions) - `aiolabs/lnbits#17` — the cascading PR whose m002 NULLs `accounts.prvkey` - `aiolabs/lnbits#9` — parent: operator-IdP framing, signer abstraction - `aiolabs/restaurant#<TBD>` — sister migration for the same pattern (filed in parallel) - `aiolabs/events#<TBD>` — sister migration - `~/dev/coordination/log.md` 2026-05-26T19:30Z — cross-session coordination entry
Author
Owner

Update 2026-05-27 — async sign_event API confirmed via aiolabs/lnbits#24

NostrSigner.sign_event is migrating to async def in aiolabs/lnbits#24 (open, stacked on PR #19). When that lands, the canonical migration pattern becomes:

from lnbits.core.signers import resolve_for_wallet

async def _resolve_signer(wallet_id: str) -> NostrSigner | None:
    return await resolve_for_wallet(wallet_id)

# In nostr_publisher.py inner functions:
async def publish_task_event(nostr_client, task, signer: NostrSigner, delete=False):
    unsigned = build_task_event_unsigned(task, signer.pubkey, delete=delete)
    signed = await signer.sign_event(unsigned)   # ← await form
    nostr_event = NostrEvent(**signed)
    await nostr_client.publish_nostr_event(nostr_event)

Two notes:

  1. resolve_for_wallet(wallet_id) (lands in aiolabs/lnbits#23) collapses the entire _resolve_keys helper into one call.
  2. await signer.sign_event(event) works for all three concrete signers; LocalSigner and ClientSideOnlySigner are sync in body but expose async signature for ABC consistency. Zero runtime cost.

sign_nostr_event extension-local helper can be removed — signer handles it.

Acceptance items remain the same; write the await form from day one.

## Update 2026-05-27 — async sign_event API confirmed via `aiolabs/lnbits#24` `NostrSigner.sign_event` is migrating to `async def` in `aiolabs/lnbits#24` (open, stacked on PR #19). When that lands, the canonical migration pattern becomes: ```python from lnbits.core.signers import resolve_for_wallet async def _resolve_signer(wallet_id: str) -> NostrSigner | None: return await resolve_for_wallet(wallet_id) # In nostr_publisher.py inner functions: async def publish_task_event(nostr_client, task, signer: NostrSigner, delete=False): unsigned = build_task_event_unsigned(task, signer.pubkey, delete=delete) signed = await signer.sign_event(unsigned) # ← await form nostr_event = NostrEvent(**signed) await nostr_client.publish_nostr_event(nostr_event) ``` Two notes: 1. **`resolve_for_wallet(wallet_id)`** (lands in `aiolabs/lnbits#23`) collapses the entire `_resolve_keys` helper into one call. 2. **`await signer.sign_event(event)`** works for all three concrete signers; `LocalSigner` and `ClientSideOnlySigner` are sync in body but expose async signature for ABC consistency. Zero runtime cost. `sign_nostr_event` extension-local helper can be removed — signer handles it. Acceptance items remain the same; write the await form from day one.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/tasks#3
No description provided.