Compare commits

..

1 commit

Author SHA1 Message Date
d29d4dbec9 feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#11)
Closes aiolabs/restaurant#11. 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 restaurant extension reads no plaintext nsec and
works with any NostrSigner backend (LocalSigner / RemoteBunkerSigner
/ ClientSideOnlySigner).

## What changed

### views_api.py — _resolve_signing_keypair → _resolve_signer

Was: `_resolve_signing_keypair(restaurant)` returned `(pubkey, prvkey)`
read directly from `account.pubkey` / `account.prvkey` after walking
wallet → account.

Now: `_resolve_signer(restaurant)` returns `NostrSigner | None`.
Precedence order preserved:

  1. `restaurant.nostr_pubkey` set → per-restaurant identity. Still
     a no-op TODO returning None until a per-restaurant signer /
     vault ships (separate concern, future work).
  2. fallback → `resolve_for_wallet(restaurant.wallet)` (the DRY
     helper from aiolabs/lnbits#23 — wallet → account → signer →
     can_sign-check in one call, returns None on any soft-fail).

Three call sites updated (`_publish_restaurant`, `_publish_menu_item`,
`_publish_menu_item_delete`): each now passes the resolved `signer`
to `publish_event` instead of the keypair tuple, and uses
`signer.pubkey` for tag construction. The discovery-echo line in
`_publish_restaurant` (`restaurant.nostr_pubkey = signer.pubkey`)
preserves prior behavior.

Dropped now-unused imports: `get_account`, `get_wallet`.

### nostr_publisher.py — publish_event

Was: `publish_event(client, event, private_key_hex)` called a local
`sign_nostr_event` helper that signed in place via
`coincurve.PrivateKey.sign_schnorr`.

Now: `publish_event(client, event, signer: NostrSigner)` builds the
unsigned dict (`kind`/`created_at`/`tags`/`content`), hands it to
`await signer.sign_event(...)`, and writes `id`/`pubkey`/`sig` back
onto the local `NostrEvent` model before publishing. 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.

### docs/nostr-layer.md — signing prose

Updated the Signing section to reflect the signer-abstraction model:
`resolve_for_wallet` resolves a `NostrSigner`, the extension no
longer touches `account.prvkey` or calls `coincurve.sign_schnorr`
directly. The per-restaurant-identity TODO is preserved.

## Acceptance

- [x] `_resolve_signing_keypair` replaced with `_resolve_signer` returning NostrSigner
- [x] `sign_nostr_event` helper removed (signer handles it internally)
- [x] `publish_event` accepts a NostrSigner instead of private_key_hex
- [x] all three call sites updated to pass the signer
- [x] re-grep `restaurant/`: zero `account.prvkey` references
- [x] coincurve import dropped
- [x] docs/nostr-layer.md updated in the same commit

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/restaurant#11 — issue this commit closes
- aiolabs/lnbits#17 — the cascading signer-abstraction PR
- aiolabs/lnbits#23 — the resolve_for_wallet helper this uses
- aiolabs/lnbits#21 — umbrella audit (5 affected extensions)
- aiolabs/events#23 / aiolabs/tasks#3 — sister migrations (already on signer-abstraction branches)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:26:41 +02:00
6 changed files with 68 additions and 89 deletions

View file

@ -70,26 +70,6 @@ Both use the same data source (`GET /restaurants/{id}/orders`)
filtered by status. The KDS view escalates color by age (`>5min`
orange, `>15min` red) and offers one-tap state transitions.
When a card transitions to `accepted` (driven by `cookingMode(order)`
in `kds.js`), three inline `:style` bindings kick in:
- the items `q-card-section` switches base font between `1rem` and
`1.25rem`,
- the modifier list (`.text-caption.text-grey-7`) bumps to `1.15rem`
+ medium weight + `color: inherit` (drops the muted grey),
- the per-line note (`.text-caption.text-amber-9`) bumps to `1.15rem`
+ medium weight; color is left alone so it stays amber.
All cooking-mode styling is inline because an upstream `!important`
rule (likely an lnbits theme override on Quasar's typography
utilities) defeats class-based CSS rules — even with `!important`
on our side. Inline `:style` wins without needing the arms race.
Card chrome and the age-based `bg-{color}-1` from `cardClass()`
are untouched. The amber
per-line note keeps its color because only `.text-grey-7` is
overridden. No background rules; card chrome and the age-based
`bg-{color}-1` from `cardClass()` are untouched.
Today the monitor + KDS poll every 58 s. SSE / Nostr push is on
the roadmap.

View file

@ -54,15 +54,20 @@ the new tag set lands.
Each restaurant has an effective Nostr identity:
- If `restaurant.nostr_pubkey` is set, that's a per-restaurant
identity (storage of the matching secret key is **out of scope**
in v1; the column is informational until a vault is wired up).
- Otherwise, the LNbits Account keypair of the wallet owner is
used (`account.pubkey` / `account.prvkey`).
identity (a per-restaurant signer/vault is **out of scope**
in v1; the column is informational until that's wired up).
- Otherwise, the wallet owner's signer is resolved via
`lnbits.core.signers.resolve_for_wallet` (wallet → account →
signer, with a `can_sign()` gate). The signer backend
(`LocalSigner` / `RemoteBunkerSigner` / `ClientSideOnlySigner`)
is transparent to this extension.
`nostr_publisher.publish_event(client, event, prvkey)` signs in
place with `coincurve.PrivateKey.sign_schnorr` (BIP-340) and ships
to the relay via the [[architecture|nostrclient extension's]]
internal WebSocket.
`nostr_publisher.publish_event(client, event, signer)` hands the
unsigned event dict to the resolved `NostrSigner`, which fills in
`id` / `pubkey` / `sig` per NIP-01 / BIP-340 and ships to the relay
via the [[architecture|nostrclient extension's]] internal WebSocket.
The extension itself does no direct Schnorr signing — that lives in
the signer abstraction.
## What gets listened for

View file

@ -15,11 +15,13 @@ itself has no awareness of festivals.
Signing
-------
Events are signed with the *restaurant's* keypair if `restaurant.nostr_pubkey`
is set, otherwise with the LNbits Account keypair of the wallet's owner.
This lets a single LNbits account host multiple restaurants under
distinct Nostr identities, while keeping a sane default for owners
who don't care about identity separation.
Events are signed via the core `NostrSigner` abstraction
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The caller
resolves a signer for the restaurant's effective Nostr identity and
hands it to `publish_event` the signer fills in `id`/`pubkey`/`sig`
per NIP-01. This lets a single LNbits account host multiple
restaurants under distinct Nostr identities while keeping a sane
default for owners who don't care about identity separation.
"""
import json
@ -27,7 +29,7 @@ import re
import time
from typing import Optional
import coincurve
from lnbits.core.signers import NostrSigner
from loguru import logger
from .models import MenuItem, Restaurant
@ -202,25 +204,33 @@ def build_delete_event(
# --------------------------------------------------------------------- #
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Schnorr-sign a NostrEvent in place (BIP-340)."""
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_event(
nostr_client,
nostr_event: NostrEvent,
private_key_hex: str,
signer: NostrSigner,
) -> Optional[NostrEvent]:
"""Sign and publish a built NostrEvent. Returns the event on success
so callers can persist its id + created_at, or None on failure."""
so callers can persist its id + created_at, or None on failure.
The unsigned event dict (`kind`/`created_at`/`tags`/`content`) is
handed to the signer, which fills in `id`/`pubkey`/`sig` same
NIP-01 serialization rules as our local `event_id` property uses,
so the returned id matches what we'd have computed locally. The
signer backend (LocalSigner / RemoteBunkerSigner) is transparent."""
if not nostr_client:
logger.debug("[RESTAURANT] No NostrClient; skipping publish")
return None
try:
sign_nostr_event(nostr_event, private_key_hex)
unsigned = {
"kind": nostr_event.kind,
"created_at": nostr_event.created_at,
"tags": nostr_event.tags,
"content": nostr_event.content,
}
signed = await signer.sign_event(unsigned)
nostr_event.id = signed["id"]
nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"]
await nostr_client.publish_nostr_event(nostr_event)
logger.info(
f"[RESTAURANT] Published kind {nostr_event.kind} "

View file

@ -23,9 +23,6 @@ window.app = Vue.createApp({
statusColor(status) {
return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey'
},
cookingMode(order) {
return order && order.status === 'accepted'
},
cardClass(order) {
// Visually escalate as orders age. >5min = highlight; >15min = alarm.
//

View file

@ -37,9 +37,7 @@
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section
:style="{'font-size': cookingMode(order) ? '1.25rem' : '1rem'}"
>
<q-card-section style="font-size: 1rem">
<div v-for="line in order._items || []" :key="line.id">
<div>
<strong v-text="line.quantity + 'x'"></strong>
@ -48,7 +46,6 @@
<div
v-if="line.selected_modifiers && line.selected_modifiers.length"
class="text-caption text-grey-7 q-pl-md"
:style="cookingMode(order) ? 'font-size: 1.15rem; font-weight: 500; color: inherit' : ''"
>
<span
v-for="(m, i) in line.selected_modifiers"
@ -61,7 +58,6 @@
<div
v-if="line.note"
class="text-caption text-amber-9 q-pl-md"
:style="cookingMode(order) ? 'font-size: 1.15rem; font-weight: 500' : ''"
>
<q-icon name="info" size="xs"></q-icon>
<span v-text="line.note"></span>

View file

@ -22,9 +22,8 @@ from pydantic import BaseModel
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Account, WalletTypeInfo
from lnbits.core.signers import NostrSigner, resolve_for_wallet
from lnbits.decorators import (
check_admin,
check_user_exists,
@ -108,53 +107,47 @@ restaurant_api_router = APIRouter()
# --------------------------------------------------------------------- #
async def _resolve_signing_keypair(
async def _resolve_signer(
restaurant: Restaurant,
) -> Optional[tuple[str, str]]:
) -> Optional[NostrSigner]:
"""
Resolve the (pubkey, prvkey) pair for signing Nostr events on behalf
of a restaurant.
Resolve a `NostrSigner` for signing events on behalf of a restaurant.
Order of precedence:
1. restaurant.nostr_pubkey is set use a per-restaurant key.
(Storage of the corresponding prvkey is intentionally out of
scope here; for now this branch is a no-op until we ship a
secret-management approach. Returns None.)
2. Otherwise fall back to the LNbits Account keypair of the
wallet owner.
1. restaurant.nostr_pubkey is set per-restaurant identity.
(A per-restaurant signer/vault is out of scope here; until
that lands this branch is a no-op. Returns None.)
2. Otherwise fall back to the wallet owner's signer via
`resolve_for_wallet` (wallet account signer with a
can_sign-check; soft-fails to None on missing wallet, missing
account, unclassified row, or ClientSideOnlySigner accounts
where the server has no signing authority).
"""
if restaurant.nostr_pubkey:
# TODO: per-restaurant secret key vault.
# TODO: per-restaurant signer / secret vault.
return None
wallet_obj = await get_wallet(restaurant.wallet)
if not wallet_obj:
return None
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
return await resolve_for_wallet(restaurant.wallet)
async def _publish_restaurant(restaurant: Restaurant) -> None:
settings = await get_settings()
if not settings.nostr_publish_enabled:
return
keypair = await _resolve_signing_keypair(restaurant)
if not keypair:
signer = await _resolve_signer(restaurant)
if signer is None:
return
pubkey, prvkey = keypair
from . import nostr_client
event = build_restaurant_metadata_event(restaurant, pubkey)
published = await publish_event(nostr_client, event, prvkey)
event = build_restaurant_metadata_event(restaurant, signer.pubkey)
published = await publish_event(nostr_client, event, signer)
if published:
restaurant.nostr_event_id = published.id
restaurant.nostr_event_created_at = published.created_at
if not restaurant.nostr_pubkey:
# Echo back the resolved pubkey so the row carries it for
# discovery (e.g. webapp follows this pubkey).
restaurant.nostr_pubkey = pubkey
restaurant.nostr_pubkey = signer.pubkey
await update_restaurant(restaurant)
@ -188,18 +181,17 @@ async def _publish_menu_item(item: MenuItem) -> None:
restaurant = await get_restaurant(item.restaurant_id)
if not restaurant:
return
keypair = await _resolve_signing_keypair(restaurant)
if not keypair:
signer = await _resolve_signer(restaurant)
if signer is None:
return
pubkey, prvkey = keypair
from . import nostr_client
ancestors = await _ancestor_names_for_node(item.node_id)
event = build_menu_item_event(
item, restaurant, pubkey, ancestor_names=ancestors
item, restaurant, signer.pubkey, ancestor_names=ancestors
)
published = await publish_event(nostr_client, event, prvkey)
published = await publish_event(nostr_client, event, signer)
if published:
item.nostr_event_id = published.id
item.nostr_event_created_at = published.created_at
@ -213,15 +205,14 @@ async def _publish_menu_item_delete(item: MenuItem) -> None:
restaurant = await get_restaurant(item.restaurant_id)
if not restaurant:
return
keypair = await _resolve_signing_keypair(restaurant)
if not keypair:
signer = await _resolve_signer(restaurant)
if signer is None:
return
pubkey, prvkey = keypair
from . import nostr_client
event = build_delete_event(30402, item.id, pubkey, "Menu item removed")
await publish_event(nostr_client, event, prvkey)
event = build_delete_event(30402, item.id, signer.pubkey, "Menu item removed")
await publish_event(nostr_client, event, signer)
def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None: