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>
242 lines
8.4 KiB
Python
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
|