diff --git a/docs/cms.md b/docs/cms.md index a438095..59bef8c 100644 --- a/docs/cms.md +++ b/docs/cms.md @@ -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 5–8 s. SSE / Nostr push is on the roadmap. diff --git a/docs/nostr-layer.md b/docs/nostr-layer.md index 6711e82..db9ab92 100644 --- a/docs/nostr-layer.md +++ b/docs/nostr-layer.md @@ -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 diff --git a/nostr_publisher.py b/nostr_publisher.py index 17137fc..28f5191 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -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} " diff --git a/static/js/kds.js b/static/js/kds.js index f7fd258..ab4f0cc 100644 --- a/static/js/kds.js +++ b/static/js/kds.js @@ -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. // diff --git a/templates/restaurant/kds.html b/templates/restaurant/kds.html index 8ba3052..0474f28 100644 --- a/templates/restaurant/kds.html +++ b/templates/restaurant/kds.html @@ -37,9 +37,7 @@ - +
@@ -48,7 +46,6 @@
diff --git a/views_api.py b/views_api.py index ad26520..7d44efb 100644 --- a/views_api.py +++ b/views_api.py @@ -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: