feat(signer): nostr publish via resolve_for_wallet + door-scanner stats endpoint #24

Merged
padreug merged 3 commits from signer-abstraction into main 2026-06-07 17:11:44 +00:00
Owner

Summary

Two pieces of work that piggybacked on the signer-abstraction branch:

  1. Signer-abstraction migrationnostr_hooks + nostr_publisher
    stop touching account.prvkey. The extension now signs Nostr
    publishes through resolve_for_wallet(wallet_id) and an opaque
    NostrSigner. Pre-cascade prerequisite for aiolabs/lnbits#17
    (signer abstraction phase 1), whose m002 startup job NULLs the
    legacy accounts.prvkey column. After this lands, events works
    transparently with LocalSigner / RemoteBunkerSigner /
    ClientSideOnlySigner. Closes aiolabs/events#23.

  2. Door-scanner stats endpoint
    GET /events/api/v1/tickets/event/{event_id}/stats. HTTP mirror of
    the events_list_event_tickets nostr-transport RPC for callers that
    don't hold a raw user prvkey (the webapp post-#9 in particular —
    useTicketScanner.refreshStats had no working HTTP path). Without
    this endpoint the activities scanner loaded initial counts via
    fallbacks but every post-scan refreshStats returned 404, leaving
    the "Scanned" counter visually stuck at 0 even though registrations
    landed correctly. Surfaced by aio-demo manual test on 2026-06-03.

Commits

Commit What
66076d6 feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#23)
4238b41 feat: GET /tickets/event/{event_id}/stats for door-scanner roster
1fb96bf chore: bump config.json version to 1.6.1-aio.5

Version note: the bump commit picks 1.6.1-aio.5 but the next
aio tag should be 1.6.1-aio.4 (last released aio tag was -aio.3).
Pre-merge correction landing in a follow-up commit on this branch.

Signer migration — what changed

nostr_hooks.pypublish_or_delete_nostr_event

Was: pulled (account.pubkey, account.prvkey) from the wallet owner,
passed both into the publisher. Hard-skipped publish when
account.prvkey was None.

Now: calls await resolve_for_wallet(event.wallet) (the DRY helper
from aiolabs/lnbits#23 — wallet → account → signer → can_sign
check in one call, returns None on any soft-fail). Passes the
resolved NostrSigner to the publisher. Soft-skip on None (wallet
missing, account unclassified, or ClientSideOnlySigner where the
server has no signing authority) — matches the previous "no prvkey"
behavior.

nostr_publisher.pypublish_event_to_nostr

Was: accepted (account_pubkey, account_prvkey) and signed via a
local sign_nostr_event helper that called
coincurve.PrivateKey.sign_schnorr on the plaintext nsec.

Now: accepts signer: NostrSigner. Builds the unsigned event dict
(kind/created_at/tags/content), hands it to
await signer.sign_event(...), reconstructs the local NostrEvent
model from the signed dict (id/pubkey/sig). The signer backend
is transparent.

Removed the sign_nostr_event helper entirely + dropped the
coincurve import — no direct crypto in this extension anymore.

Door-scanner stats endpoint

Auth: require_admin_key + the event's wallet must be in the caller's
user.wallet_ids (matches the register endpoint's owner check).

Response:

{
  "event_id": "...",
  "sold": 12,
  "registered": 7,
  "remaining": 5,
  "tickets": [
    {"id": "...", "name": "...", "registered": true,  "registered_at": "..."},
    {"id": "...", "name": "...", "registered": false, "registered_at": null}
  ]
}

Only paid tickets are included (consistent with the door-scanner's
mental model — unpaid tickets don't exist on the roster).

Acceptance — signer migration

  • keypair helper replaced (nostr_hooks no longer touches
    account.prvkey)
  • publish_event_to_nostr accepts NostrSigner instead of
    (pubkey, prvkey)
  • extension-local Schnorr code removed (sign_nostr_event gone)
  • grep -r account.prvkey across events/ → zero matches
  • config.json bumped

Cross-references

  • aiolabs/events#23 — issue the signer migration closes
  • aiolabs/lnbits#17 — the cascading signer-abstraction PR
  • aiolabs/lnbits#23 — the resolve_for_wallet helper this consumes
  • aiolabs/lnbits#26 — phase 2.3 (sign_event over bunker, validated
    against aiolabs/nsecbunkerd@fb1c239)
  • aiolabs/lnbits#21 — umbrella audit identifying 5 affected
    extensions

Test plan

Signer migration:

  • Approve an event whose wallet owner has a LocalSigner-backed
    account → kind 31922/31923 publish lands on configured relays,
    event.nostr_event_id populated.
  • Cancel/delete the event → kind 5 delete event lands.
  • Approve an event whose wallet owner has a ClientSideOnlySigner
    account → publish soft-skips silently (no exception, no crash).
  • Approve an event whose wallet owner has no Nostr identity at all
    → same soft-skip behavior as before.
  • After aiolabs/lnbits#17's m002 NULLs accounts.prvkey, run
    the above three again → same outcomes (proves the migration is
    not silently using the legacy column).

Door-scanner stats endpoint:

  • Owner's admin_key against /tickets/event/{event_id}/stats
    200 with the four scalar counters + per-ticket roster.
  • No admin_key → 401.
  • Wrong-wallet admin_key (caller does not own the event's wallet)
    → 403 "You do not own this event."
  • Unknown event_id → 404 "Event does not exist."
  • Roster excludes unpaid tickets and includes registered +
    unregistered paid ones.
  • Webapp activities scanner: scan a QR, watch refreshStats
    Scanned counter increments without a page reload.

🤖 Generated with Claude Code

## Summary Two pieces of work that piggybacked on the `signer-abstraction` branch: 1. **Signer-abstraction migration** — `nostr_hooks` + `nostr_publisher` stop touching `account.prvkey`. The extension now signs Nostr publishes through `resolve_for_wallet(wallet_id)` and an opaque `NostrSigner`. Pre-cascade prerequisite for `aiolabs/lnbits#17` (signer abstraction phase 1), whose `m002` startup job NULLs the legacy `accounts.prvkey` column. After this lands, events works transparently with `LocalSigner` / `RemoteBunkerSigner` / `ClientSideOnlySigner`. Closes `aiolabs/events#23`. 2. **Door-scanner stats endpoint** — `GET /events/api/v1/tickets/event/{event_id}/stats`. HTTP mirror of the `events_list_event_tickets` nostr-transport RPC for callers that don't hold a raw user prvkey (the webapp post-#9 in particular — `useTicketScanner.refreshStats` had no working HTTP path). Without this endpoint the activities scanner loaded initial counts via fallbacks but every post-scan `refreshStats` returned 404, leaving the "Scanned" counter visually stuck at 0 even though registrations landed correctly. Surfaced by aio-demo manual test on 2026-06-03. ## Commits | Commit | What | |---|---| | `66076d6` | `feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#23)` | | `4238b41` | `feat: GET /tickets/event/{event_id}/stats for door-scanner roster` | | `1fb96bf` | `chore: bump config.json version to 1.6.1-aio.5` | > **Version note:** the bump commit picks `1.6.1-aio.5` but the next > aio tag should be `1.6.1-aio.4` (last released aio tag was `-aio.3`). > Pre-merge correction landing in a follow-up commit on this branch. ## Signer migration — what changed ### `nostr_hooks.py` — `publish_or_delete_nostr_event` Was: pulled `(account.pubkey, account.prvkey)` from the wallet owner, passed both into the publisher. Hard-skipped publish when `account.prvkey` was `None`. Now: calls `await resolve_for_wallet(event.wallet)` (the DRY helper from `aiolabs/lnbits#23` — wallet → account → signer → `can_sign` check in one call, returns `None` on any soft-fail). Passes the resolved `NostrSigner` to the publisher. Soft-skip on `None` (wallet missing, account unclassified, or `ClientSideOnlySigner` where the server has no signing authority) — matches the previous "no prvkey" behavior. ### `nostr_publisher.py` — `publish_event_to_nostr` Was: accepted `(account_pubkey, account_prvkey)` and signed via a local `sign_nostr_event` helper that called `coincurve.PrivateKey.sign_schnorr` on the plaintext nsec. Now: accepts `signer: NostrSigner`. Builds the unsigned event dict (`kind`/`created_at`/`tags`/`content`), hands it to `await signer.sign_event(...)`, reconstructs the local `NostrEvent` model from the signed dict (`id`/`pubkey`/`sig`). The signer backend is transparent. Removed the `sign_nostr_event` helper entirely + dropped the `coincurve` import — no direct crypto in this extension anymore. ## Door-scanner stats endpoint Auth: `require_admin_key` + the event's wallet must be in the caller's `user.wallet_ids` (matches the register endpoint's owner check). Response: ```json { "event_id": "...", "sold": 12, "registered": 7, "remaining": 5, "tickets": [ {"id": "...", "name": "...", "registered": true, "registered_at": "..."}, {"id": "...", "name": "...", "registered": false, "registered_at": null} ] } ``` Only paid tickets are included (consistent with the door-scanner's mental model — unpaid tickets don't exist on the roster). ## Acceptance — signer migration - [x] keypair helper replaced (nostr_hooks no longer touches `account.prvkey`) - [x] `publish_event_to_nostr` accepts `NostrSigner` instead of `(pubkey, prvkey)` - [x] extension-local Schnorr code removed (`sign_nostr_event` gone) - [x] `grep -r account.prvkey` across `events/` → zero matches - [x] config.json bumped ## Cross-references - `aiolabs/events#23` — issue the signer migration closes - `aiolabs/lnbits#17` — the cascading signer-abstraction PR - `aiolabs/lnbits#23` — the `resolve_for_wallet` helper this consumes - `aiolabs/lnbits#26` — phase 2.3 (`sign_event` over bunker, validated against `aiolabs/nsecbunkerd@fb1c239`) - `aiolabs/lnbits#21` — umbrella audit identifying 5 affected extensions ## Test plan **Signer migration:** - [ ] Approve an event whose wallet owner has a `LocalSigner`-backed account → kind 31922/31923 publish lands on configured relays, `event.nostr_event_id` populated. - [ ] Cancel/delete the event → kind 5 delete event lands. - [ ] Approve an event whose wallet owner has a `ClientSideOnlySigner` account → publish soft-skips silently (no exception, no crash). - [ ] Approve an event whose wallet owner has no Nostr identity at all → same soft-skip behavior as before. - [ ] After `aiolabs/lnbits#17`'s `m002` NULLs `accounts.prvkey`, run the above three again → same outcomes (proves the migration is not silently using the legacy column). **Door-scanner stats endpoint:** - [ ] Owner's `admin_key` against `/tickets/event/{event_id}/stats` → 200 with the four scalar counters + per-ticket roster. - [ ] No `admin_key` → 401. - [ ] Wrong-wallet `admin_key` (caller does not own the event's wallet) → 403 `"You do not own this event."` - [ ] Unknown `event_id` → 404 `"Event does not exist."` - [ ] Roster excludes unpaid tickets and includes registered + unregistered paid ones. - [ ] Webapp activities scanner: scan a QR, watch `refreshStats` → `Scanned` counter increments without a page reload. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Closes aiolabs/events#23. Pre-cascade prerequisite for aiolabs/lnbits#17
(signer abstraction phase 1), which lands an m002 startup job that
NULLs the legacy `accounts.prvkey` column. After this migration, the
events extension reads no plaintext nsec and works with any
NostrSigner backend (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner).

## What changed

### nostr_hooks.py — publish_or_delete_nostr_event

Was: pulled `(account.pubkey, account.prvkey)` from the wallet owner,
passed both to `publish_event_to_nostr`. Hard-skipped publish when
`account.prvkey` was None.

Now: calls `await resolve_for_wallet(event.wallet)` (the DRY helper
from aiolabs/lnbits#23 — wallet → account → signer → can_sign-check
in one call, returns None on any soft-fail). Passes the resolved
`NostrSigner` to the publisher. Soft-skip on None (wallet missing,
account unclassified, or ClientSideOnlySigner where the server has
no signing authority) — matching previous "no prvkey" behavior.

### nostr_publisher.py — publish_event_to_nostr

Was: accepted `(account_pubkey, account_prvkey)` and signed via a
local `sign_nostr_event` helper that called `coincurve.PrivateKey
.sign_schnorr` directly on the plaintext nsec.

Now: accepts `signer: NostrSigner`. Builds the unsigned event dict
(`kind`/`created_at`/`tags`/`content`), hands it to
`await signer.sign_event(...)`, reconstructs the local `NostrEvent`
model from the signed dict (`id`/`pubkey`/`sig` fields). The signer
backend (LocalSigner / RemoteBunkerSigner) is transparent.

Removed the `sign_nostr_event` helper entirely — the signer abstraction
handles all signing now.

Dropped the `coincurve` import; no direct crypto in this extension.

## Acceptance

- [x] keypair helper replaced (nostr_hooks no longer touches account.prvkey)
- [x] publish_event_to_nostr accepts NostrSigner instead of (pubkey, prvkey)
- [x] extension-local Schnorr code removed (sign_nostr_event gone)
- [x] re-grep `events/`: zero `account.prvkey` references
- [x] version bumped: 1.6.1-aio.3 → 1.6.1-aio.4

Manual smoke testing + tag + catalog entry follow the migration
landing; will run against the regtest stack with lnbits on
`issue-18-phase-2.3` (which validates both LocalSigner and
RemoteBunkerSigner signing paths end-to-end).

## Cross-references

- aiolabs/events#23 — issue this commit closes
- aiolabs/lnbits#17 — the cascading signer-abstraction PR
- aiolabs/lnbits#23 — the resolve_for_wallet helper this uses
- aiolabs/lnbits#26 — phase 2.3 (sign_event over bunker, validated against
  aiolabs/nsecbunkerd@fb1c239)
- aiolabs/lnbits#21 — umbrella audit identifying 5 affected extensions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the events_list_event_tickets nostr-transport RPC for callers
that don't hold a raw user prvkey (the webapp post-#9, in particular —
useTicketScanner.refreshStats now has a working HTTP path). Auth:
wallet admin_key + the event's wallet must be in the caller's wallet
set, matching the register endpoint's owner check.

Without this endpoint the activities scanner page loaded its initial
counts (via no-op fallbacks) but every post-scan refreshStats returned
404, leaving the Scanned counter stuck at 0 even though registrations
landed correctly. Surfaced by aio-demo manual test on 2026-06-03.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore: bump config.json version to 1.6.1-aio.5
Some checks failed
lint.yml / chore: bump config.json version to 1.6.1-aio.5 (pull_request) Failing after 0s
1fb96bfe3c
Releases the door-scanner stats endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
padreug deleted branch signer-abstraction 2026-06-07 17:11:44 +00:00
Sign in to join this conversation.
No reviewers
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/events!24
No description provided.