diff --git a/docs/cms.md b/docs/cms.md index 59bef8c..a438095 100644 --- a/docs/cms.md +++ b/docs/cms.md @@ -70,6 +70,26 @@ 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 db9ab92..6711e82 100644 --- a/docs/nostr-layer.md +++ b/docs/nostr-layer.md @@ -54,20 +54,15 @@ the new tag set lands. Each restaurant has an effective Nostr identity: - If `restaurant.nostr_pubkey` is set, that's a per-restaurant - 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. + 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`). -`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. +`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. ## What gets listened for diff --git a/nostr_publisher.py b/nostr_publisher.py index 28f5191..17137fc 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -15,13 +15,11 @@ 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. +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. """ import json @@ -29,7 +27,7 @@ import re import time from typing import Optional -from lnbits.core.signers import NostrSigner +import coincurve from loguru import logger from .models import MenuItem, Restaurant @@ -204,33 +202,25 @@ 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, - signer: NostrSigner, + private_key_hex: str, ) -> 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.""" + so callers can persist its id + created_at, or None on failure.""" 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"] + sign_nostr_event(nostr_event, private_key_hex) 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 ab4f0cc..f7fd258 100644 --- a/static/js/kds.js +++ b/static/js/kds.js @@ -23,6 +23,9 @@ 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 0474f28..8ba3052 100644 --- a/templates/restaurant/kds.html +++ b/templates/restaurant/kds.html @@ -37,7 +37,9 @@ - +
@@ -46,6 +48,7 @@
diff --git a/views_api.py b/views_api.py index 7d44efb..ad26520 100644 --- a/views_api.py +++ b/views_api.py @@ -22,8 +22,9 @@ 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, @@ -107,47 +108,53 @@ restaurant_api_router = APIRouter() # --------------------------------------------------------------------- # -async def _resolve_signer( +async def _resolve_signing_keypair( restaurant: Restaurant, -) -> Optional[NostrSigner]: +) -> Optional[tuple[str, str]]: """ - Resolve a `NostrSigner` for signing events on behalf of a restaurant. + Resolve the (pubkey, prvkey) pair for signing Nostr events on behalf + of a restaurant. Order of precedence: - 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). + 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. """ if restaurant.nostr_pubkey: - # TODO: per-restaurant signer / secret vault. + # TODO: per-restaurant secret key vault. return None - return await resolve_for_wallet(restaurant.wallet) + 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 async def _publish_restaurant(restaurant: Restaurant) -> None: settings = await get_settings() if not settings.nostr_publish_enabled: return - signer = await _resolve_signer(restaurant) - if signer is None: + keypair = await _resolve_signing_keypair(restaurant) + if not keypair: return + pubkey, prvkey = keypair from . import nostr_client - event = build_restaurant_metadata_event(restaurant, signer.pubkey) - published = await publish_event(nostr_client, event, signer) + event = build_restaurant_metadata_event(restaurant, pubkey) + published = await publish_event(nostr_client, event, prvkey) 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 = signer.pubkey + restaurant.nostr_pubkey = pubkey await update_restaurant(restaurant) @@ -181,17 +188,18 @@ async def _publish_menu_item(item: MenuItem) -> None: restaurant = await get_restaurant(item.restaurant_id) if not restaurant: return - signer = await _resolve_signer(restaurant) - if signer is None: + keypair = await _resolve_signing_keypair(restaurant) + if not keypair: return + pubkey, prvkey = keypair from . import nostr_client ancestors = await _ancestor_names_for_node(item.node_id) event = build_menu_item_event( - item, restaurant, signer.pubkey, ancestor_names=ancestors + item, restaurant, pubkey, ancestor_names=ancestors ) - published = await publish_event(nostr_client, event, signer) + published = await publish_event(nostr_client, event, prvkey) if published: item.nostr_event_id = published.id item.nostr_event_created_at = published.created_at @@ -205,14 +213,15 @@ async def _publish_menu_item_delete(item: MenuItem) -> None: restaurant = await get_restaurant(item.restaurant_id) if not restaurant: return - signer = await _resolve_signer(restaurant) - if signer is None: + keypair = await _resolve_signing_keypair(restaurant) + if not keypair: return + pubkey, prvkey = keypair from . import nostr_client - event = build_delete_event(30402, item.id, signer.pubkey, "Menu item removed") - await publish_event(nostr_client, event, signer) + event = build_delete_event(30402, item.id, pubkey, "Menu item removed") + await publish_event(nostr_client, event, prvkey) def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None: