ClientSideOnlySigner organizers can never publish — and the #55 sweep retries them forever #57

Open
opened 2026-09-27 07:48:13 +00:00 by padreug · 1 comment
Owner

Two problems, one root

An organizer whose account is ClientSideOnlySigner has no Nostr presence at all. publish_or_delete_nostr_event resolves a signer for the event's wallet, can_sign() is false, and it returns. The event exists in LNbits and never appears on any relay. Before #54 this was completely silent.

And since #55, the sweep retries them every 5 minutes forever. The sweep's premise is "this will work later" — true for a signer outage, a dead NostrClient, a relay blip. For ClientSideOnlySigner it is false by construction: the server has no signing authority for that account and never will. So the row stays flagged, the retry can never succeed, and the log fills up.

The second is a nuisance #55 introduced. The first predates it — #55 only made it visible.

Not the same as the bunker direction

Worth separating, because they look alike. Under RemoteBunkerSigner the server still initiates signing and server-side republish keeps working; the key just lives elsewhere. The workspace's "bunker for everything, no nsec at rest" endgame therefore does not make the sweep obsolete.

ClientSideOnlySigner is the genuinely different case — the server cannot originate a signature at all. It's the only signer type the sweep can't serve.

What a real fix probably needs

  1. A terminal state distinct from nostr_publish_pending. Pending means "retry"; this means "the server is not allowed to do this". The sweep skips terminal rows, logs once rather than every 5 minutes, and "events awaiting client-side signature" becomes a queryable thing instead of noise.

  2. resolve_for_wallet surfacing why it returned None. Callers get a bare None today and cannot distinguish "outage, retry" from "not permitted, never retry". Everything above depends on that distinction, and it lives in aiolabs/lnbits core (lnbits/core/signers/resolve.py), not here. Three of its four soft-fail branches also log at debug, so the reason is invisible at the INFO level instances run at — same defect class as #51, one layer down.

  3. A client-driven publish path. The server already has build_nip52_event(event, pubkey), so it can hand a client a deterministic unsigned event to sign via NIP-07/NIP-46 and publish. This is the TODO already sitting in nostr_hooks.py:

    The user can still publish kind-31922/31923 events client-side once we have that path.

    It's also the client-agnostic destination the workspace notes aim at, so it's worth building for its own sake rather than as a workaround.

Cheap interim

Independent of the design: have the sweep skip terminal cases and log once. Kills the 5-minute noise without prejudging anything. Worth doing if a production instance turns out to have a ClientSideOnly organizer.

A no-op edit does not work around this

Worth recording, since it's the obvious first thought. api_event_update calls publish_or_delete_nostr_event unconditionally when the event is approved — there's no diff check, so saving an unchanged form really does trigger a republish. But it doesn't help here, for two reasons:

  • The signer is resolved from event.wallet, not from whoever is acting. api_event_update also requires event.wallet == wallet.wallet.id, so the editor is the event's wallet owner — a different user with a working bunker signer cannot republish someone else's event.
  • wallet is not in the update endpoint's field list, so the event can't be moved to a wallet whose account can sign.

There's also a trap in trying it: api_event_update recomputes event.status = "approved" if (is_admin or auto_approve) else "proposed". A non-admin organizer on an instance with auto_approve off who hits save takes their live event off the public feed and publishes a kind-5 delete.

Live lead on cfaun

The relay's stored copy of Q6mhQQzQPSXqkQuFndzXnf was published by pubkey ba3f45a8d618… on 2026-09-12. So resolve_for_wallet("7fe0a44ed7f342efb3ed3bcf7c18cb93") worked then and fails now — that account had a working server-side signer and lost it somewhere between Sep 12 and the Sep 21 sale.

That makes "was it always ClientSideOnly?" the wrong question. Something reclassified it, cleared its pubkey, or left it unclassified. Worth resolving before designing around it:

SELECT a.id,
       CASE WHEN a.pubkey IS NULL OR a.pubkey='' THEN 'NO' ELSE 'yes' END AS has_pubkey,
       COALESCE(a.signer_type,'(unclassified)') AS signer_type,
       CASE WHEN a.signer_config IS NULL THEN 'NULL' ELSE 'set' END AS signer_config
FROM accounts a JOIN wallets w ON w.user = a.id
WHERE w.id = '7fe0a44ed7f342efb3ed3bcf7c18cb93';

If pubkey still equals ba3f45a8d618… then the identity survived and only the signing authority changed.

Blocks nothing, but #55's sweep needs revisiting when this lands.

## Two problems, one root **An organizer whose account is `ClientSideOnlySigner` has no Nostr presence at all.** `publish_or_delete_nostr_event` resolves a signer for the event's wallet, `can_sign()` is false, and it returns. The event exists in LNbits and never appears on any relay. Before #54 this was completely silent. **And since #55, the sweep retries them every 5 minutes forever.** The sweep's premise is "this will work later" — true for a signer outage, a dead NostrClient, a relay blip. For `ClientSideOnlySigner` it is false by construction: the server has no signing authority for that account and never will. So the row stays flagged, the retry can never succeed, and the log fills up. The second is a nuisance #55 introduced. The first predates it — #55 only made it visible. ## Not the same as the bunker direction Worth separating, because they look alike. Under `RemoteBunkerSigner` the server still *initiates* signing and server-side republish keeps working; the key just lives elsewhere. The workspace's "bunker for everything, no nsec at rest" endgame therefore does **not** make the sweep obsolete. `ClientSideOnlySigner` is the genuinely different case — the server cannot originate a signature at all. It's the only signer type the sweep can't serve. ## What a real fix probably needs 1. **A terminal state distinct from `nostr_publish_pending`.** Pending means "retry"; this means "the server is not allowed to do this". The sweep skips terminal rows, logs once rather than every 5 minutes, and "events awaiting client-side signature" becomes a queryable thing instead of noise. 2. **`resolve_for_wallet` surfacing *why* it returned None.** Callers get a bare `None` today and cannot distinguish "outage, retry" from "not permitted, never retry". Everything above depends on that distinction, and it lives in `aiolabs/lnbits` core (`lnbits/core/signers/resolve.py`), not here. Three of its four soft-fail branches also log at `debug`, so the reason is invisible at the INFO level instances run at — same defect class as #51, one layer down. 3. **A client-driven publish path.** The server already has `build_nip52_event(event, pubkey)`, so it can hand a client a deterministic unsigned event to sign via NIP-07/NIP-46 and publish. This is the TODO already sitting in `nostr_hooks.py`: > The user can still publish kind-31922/31923 events client-side once we have that path. It's also the client-agnostic destination the workspace notes aim at, so it's worth building for its own sake rather than as a workaround. ## Cheap interim Independent of the design: have the sweep skip terminal cases and log once. Kills the 5-minute noise without prejudging anything. Worth doing if a production instance turns out to have a ClientSideOnly organizer. ## A no-op edit does not work around this Worth recording, since it's the obvious first thought. `api_event_update` calls `publish_or_delete_nostr_event` unconditionally when the event is approved — there's no diff check, so saving an unchanged form really does trigger a republish. But it doesn't help here, for two reasons: - The signer is resolved from **`event.wallet`**, not from whoever is acting. `api_event_update` also requires `event.wallet == wallet.wallet.id`, so the editor *is* the event's wallet owner — a different user with a working bunker signer cannot republish someone else's event. - `wallet` is not in the update endpoint's field list, so the event can't be moved to a wallet whose account can sign. There's also a trap in trying it: `api_event_update` recomputes `event.status = "approved" if (is_admin or auto_approve) else "proposed"`. A non-admin organizer on an instance with `auto_approve` off who hits save takes their live event **off** the public feed and publishes a kind-5 delete. ## Live lead on cfaun The relay's stored copy of `Q6mhQQzQPSXqkQuFndzXnf` was published by pubkey `ba3f45a8d618…` on 2026-09-12. So `resolve_for_wallet("7fe0a44ed7f342efb3ed3bcf7c18cb93")` **worked then and fails now** — that account had a working server-side signer and lost it somewhere between Sep 12 and the Sep 21 sale. That makes "was it always ClientSideOnly?" the wrong question. Something reclassified it, cleared its pubkey, or left it unclassified. Worth resolving before designing around it: ```sql SELECT a.id, CASE WHEN a.pubkey IS NULL OR a.pubkey='' THEN 'NO' ELSE 'yes' END AS has_pubkey, COALESCE(a.signer_type,'(unclassified)') AS signer_type, CASE WHEN a.signer_config IS NULL THEN 'NULL' ELSE 'set' END AS signer_config FROM accounts a JOIN wallets w ON w.user = a.id WHERE w.id = '7fe0a44ed7f342efb3ed3bcf7c18cb93'; ``` If `pubkey` still equals `ba3f45a8d618…` then the identity survived and only the signing authority changed. Blocks nothing, but #55's sweep needs revisiting when this lands.
Author
Owner

Parked — the load-bearing part is lnbits-side, not here

Deferring this. The decision that unblocks everything in it belongs in aiolabs/lnbits, not in the extension:

  • resolve_for_wallet returns a bare None for four different reasons, so this extension cannot distinguish "signer outage, retry" from "server has no authority, never retry". Every design option listed above depends on that distinction existing first.
  • Three of those four soft-fail branches log at debug (lnbits/core/signers/resolve.py:131-162), so the reason isn't even visible at the INFO level instances run at — the same defect class as #51, one layer down.

Until that lands, anything built here would be guessing at the cause from the extension side, which is how you get a heuristic that's wrong the first time a new signer type appears.

What's still true and worth not losing

  • A ClientSideOnlySigner organizer has no Nostr presence at all today — the event exists in LNbits and never reaches a relay. Silent before #54; now at least logged.
  • #55's sweep would retry such a row every 5 minutes forever, since the retry can never succeed. Nobody has hit this in production yet (cfaun's two affected accounts were bunker-bound and are now repaired), which is why it's parkable rather than urgent.
  • The cheap interim — sweep skips terminal cases and logs once — is still available if a production instance does acquire a ClientSideOnly organizer. It needs the lnbits distinction too, so it isn't a shortcut around the dependency.

Reopen for scoping once lnbits can say why a signer resolution failed.

## Parked — the load-bearing part is lnbits-side, not here Deferring this. The decision that unblocks everything in it belongs in `aiolabs/lnbits`, not in the extension: - `resolve_for_wallet` returns a bare `None` for four different reasons, so this extension **cannot** distinguish "signer outage, retry" from "server has no authority, never retry". Every design option listed above depends on that distinction existing first. - Three of those four soft-fail branches log at `debug` (`lnbits/core/signers/resolve.py:131-162`), so the reason isn't even visible at the INFO level instances run at — the same defect class as #51, one layer down. Until that lands, anything built here would be guessing at the cause from the extension side, which is how you get a heuristic that's wrong the first time a new signer type appears. ## What's still true and worth not losing - A `ClientSideOnlySigner` organizer has **no Nostr presence at all** today — the event exists in LNbits and never reaches a relay. Silent before #54; now at least logged. - #55's sweep would retry such a row every 5 minutes forever, since the retry can never succeed. Nobody has hit this in production yet (cfaun's two affected accounts were bunker-bound and are now repaired), which is why it's parkable rather than urgent. - The cheap interim — sweep skips terminal cases and logs once — is still available if a production instance does acquire a ClientSideOnly organizer. It needs the lnbits distinction too, so it isn't a shortcut around the dependency. Reopen for scoping once lnbits can say *why* a signer resolution failed.
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/events#57
No description provided.