restaurant/nostr_publisher.py
Padreug 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

242 lines
8.4 KiB
Python

"""
Nostr publishing for the restaurant extension.
Three event types are published:
1. Restaurant profile → kind 0 (NIP-01 metadata)
2. Menu items → kind 30402 (NIP-99 classified listing,
parameterized replaceable)
3. Deletions → kind 5 (NIP-09 deletion request)
Customer-facing webapps subscribe to a restaurant's pubkey to assemble
its menu in real time. Festivals / collective spaces are external curated
lists (NIP-51) that simply enumerate restaurant pubkeys; the extension
itself has no awareness of festivals.
Signing
-------
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
import re
import time
from typing import Optional
from lnbits.core.signers import NostrSigner
from loguru import logger
from .models import MenuItem, Restaurant
from .nostr.event import NostrEvent
_SLUG_NON_ALNUM = re.compile(r"[^a-z0-9]+")
def _slugify(name: str) -> str:
"""
Convert a node name to a lowercase ASCII slug suitable for the
Nostr `t` (hashtag) tag. Relays and clients filter on `#t` values
by exact match, so 'Hot Beverages' must become 'hot-beverages'
for the filter `{"#t": ["hot-beverages"]}` to find it.
Best-effort:
* Lowercase
* Replace any run of non-alphanumeric characters with '-'
* Strip leading/trailing dashes
* Empty / whitespace-only input returns ''
"""
s = (name or "").strip().lower()
s = _SLUG_NON_ALNUM.sub("-", s)
s = s.strip("-")
return s
# --------------------------------------------------------------------- #
# Builders #
# --------------------------------------------------------------------- #
def build_restaurant_metadata_event(restaurant: Restaurant, pubkey: str) -> NostrEvent:
"""
Build a kind 0 (NIP-01 metadata) event for a restaurant profile.
`content` is a JSON object with the canonical metadata fields
(`name`, `about`, `picture`, `banner`, `website`, ...).
"""
content = {
"name": restaurant.name,
"display_name": restaurant.name,
"about": restaurant.description or "",
}
if restaurant.logo_url:
content["picture"] = restaurant.logo_url
if restaurant.banner_url:
content["banner"] = restaurant.banner_url
if restaurant.social_links.website:
content["website"] = restaurant.social_links.website
tags: list[list[str]] = [["t", "restaurant"]]
if restaurant.location:
tags.append(["location", restaurant.location])
if restaurant.geohash:
tags.append(["g", restaurant.geohash])
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=0,
tags=tags,
content=json.dumps(content, separators=(",", ":"), ensure_ascii=False),
)
nostr_event.id = nostr_event.event_id
return nostr_event
def build_menu_item_event(
item: MenuItem,
restaurant: Restaurant,
pubkey: str,
*,
ancestor_names: tuple[str, ...] = (),
) -> NostrEvent:
"""
Build a NIP-99 classified listing (kind 30402) for a menu item.
Tags
----
d item.id (addressable identifier — replaceable per NIP-33)
title item.name
summary item.description (truncated, optional)
price [price, "<amount>", "<currency>"]
image each entry in item.images
t "menu", each ancestor node name (slugified, root-first),
each dietary tag, each allergen (prefixed `allergen:`),
each ingredient (prefixed `ingr:`)
l "restaurant:<restaurant.id>" (link back to the operator)
location restaurant.location (if set)
g restaurant.geohash (if set)
status "active" | "sold" (NIP-99 standard) — sold-out state
`ancestor_names` is the chain of node names from the root down to
(and including) the item's own node, e.g.
("Drinks", "Hot Beverages", "Coffee-based")
Each is slugified to lowercase ASCII so `#t=hot-beverages` filters
work cleanly. Caller (views_api._publish_menu_item) walks the
materialized path; this builder stays DB-free.
Content is markdown — currently `item.description`; can be expanded
later to include rich allergen/ingredient blocks.
"""
price_currency = (item.currency or "sat").upper()
tags: list[list[str]] = [
["d", item.id],
["title", item.name],
["price", f"{item.price:g}", price_currency],
["l", f"restaurant:{restaurant.id}"],
["t", "menu"],
]
if item.description:
tags.append(["summary", item.description[:140]])
for img in item.images or []:
tags.append(["image", img])
# Ancestor categories — slugified, deduped, root-first.
seen_slugs: set[str] = set()
for ancestor in ancestor_names:
slug = _slugify(ancestor)
if slug and slug not in seen_slugs:
seen_slugs.add(slug)
tags.append(["t", slug])
for diet in item.dietary or []:
tags.append(["t", diet])
for allergen in item.allergens or []:
tags.append(["t", f"allergen:{allergen}"])
for ingredient in item.ingredients or []:
tags.append(["t", f"ingr:{ingredient}"])
if restaurant.location:
tags.append(["location", restaurant.location])
if restaurant.geohash:
tags.append(["g", restaurant.geohash])
sold_out = item.stock is not None and item.stock <= 0
tags.append(["status", "sold" if sold_out or not item.is_available else "active"])
content = item.description or item.name
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=30402,
tags=tags,
content=content,
)
nostr_event.id = nostr_event.event_id
return nostr_event
def build_delete_event(
addressable_kind: int, identifier: str, pubkey: str, reason: str = ""
) -> NostrEvent:
"""
Build a NIP-09 deletion request (kind 5) for a parameterized
replaceable event. `addressable_kind` is the kind of the target
(e.g. 30402 for a menu item) and `identifier` is the `d`-tag.
"""
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=5,
tags=[["a", f"{addressable_kind}:{pubkey}:{identifier}"]],
content=reason,
)
nostr_event.id = nostr_event.event_id
return nostr_event
# --------------------------------------------------------------------- #
# Signing + publishing #
# --------------------------------------------------------------------- #
async def publish_event(
nostr_client,
nostr_event: NostrEvent,
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.
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:
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} "
f"event {nostr_event.id[:16]}..."
)
return nostr_event
except Exception as e:
logger.warning(f"[RESTAURANT] Failed to publish: {e}")
return None