Compare commits
1 commit
main
...
signer-abs
| Author | SHA1 | Date | |
|---|---|---|---|
| d29d4dbec9 |
6 changed files with 68 additions and 89 deletions
20
docs/cms.md
20
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`
|
filtered by status. The KDS view escalates color by age (`>5min`
|
||||||
orange, `>15min` red) and offers one-tap state transitions.
|
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
|
Today the monitor + KDS poll every 5–8 s. SSE / Nostr push is on
|
||||||
the roadmap.
|
the roadmap.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,15 +54,20 @@ the new tag set lands.
|
||||||
Each restaurant has an effective Nostr identity:
|
Each restaurant has an effective Nostr identity:
|
||||||
|
|
||||||
- If `restaurant.nostr_pubkey` is set, that's a per-restaurant
|
- If `restaurant.nostr_pubkey` is set, that's a per-restaurant
|
||||||
identity (storage of the matching secret key is **out of scope**
|
identity (a per-restaurant signer/vault is **out of scope**
|
||||||
in v1; the column is informational until a vault is wired up).
|
in v1; the column is informational until that's wired up).
|
||||||
- Otherwise, the LNbits Account keypair of the wallet owner is
|
- Otherwise, the wallet owner's signer is resolved via
|
||||||
used (`account.pubkey` / `account.prvkey`).
|
`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
|
`nostr_publisher.publish_event(client, event, signer)` hands the
|
||||||
place with `coincurve.PrivateKey.sign_schnorr` (BIP-340) and ships
|
unsigned event dict to the resolved `NostrSigner`, which fills in
|
||||||
to the relay via the [[architecture|nostrclient extension's]]
|
`id` / `pubkey` / `sig` per NIP-01 / BIP-340 and ships to the relay
|
||||||
internal WebSocket.
|
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
|
## What gets listened for
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@ itself has no awareness of festivals.
|
||||||
|
|
||||||
Signing
|
Signing
|
||||||
-------
|
-------
|
||||||
Events are signed with the *restaurant's* keypair if `restaurant.nostr_pubkey`
|
Events are signed via the core `NostrSigner` abstraction
|
||||||
is set, otherwise with the LNbits Account keypair of the wallet's owner.
|
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The caller
|
||||||
This lets a single LNbits account host multiple restaurants under
|
resolves a signer for the restaurant's effective Nostr identity and
|
||||||
distinct Nostr identities, while keeping a sane default for owners
|
hands it to `publish_event` — the signer fills in `id`/`pubkey`/`sig`
|
||||||
who don't care about identity separation.
|
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 json
|
||||||
|
|
@ -27,7 +29,7 @@ import re
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import coincurve
|
from lnbits.core.signers import NostrSigner
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .models import MenuItem, Restaurant
|
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(
|
async def publish_event(
|
||||||
nostr_client,
|
nostr_client,
|
||||||
nostr_event: NostrEvent,
|
nostr_event: NostrEvent,
|
||||||
private_key_hex: str,
|
signer: NostrSigner,
|
||||||
) -> Optional[NostrEvent]:
|
) -> Optional[NostrEvent]:
|
||||||
"""Sign and publish a built NostrEvent. Returns the event on success
|
"""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:
|
if not nostr_client:
|
||||||
logger.debug("[RESTAURANT] No NostrClient; skipping publish")
|
logger.debug("[RESTAURANT] No NostrClient; skipping publish")
|
||||||
return None
|
return None
|
||||||
try:
|
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)
|
await nostr_client.publish_nostr_event(nostr_event)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[RESTAURANT] Published kind {nostr_event.kind} "
|
f"[RESTAURANT] Published kind {nostr_event.kind} "
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,6 @@ window.app = Vue.createApp({
|
||||||
statusColor(status) {
|
statusColor(status) {
|
||||||
return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey'
|
return {paid: 'positive', accepted: 'blue', ready: 'amber'}[status] || 'grey'
|
||||||
},
|
},
|
||||||
cookingMode(order) {
|
|
||||||
return order && order.status === 'accepted'
|
|
||||||
},
|
|
||||||
cardClass(order) {
|
cardClass(order) {
|
||||||
// Visually escalate as orders age. >5min = highlight; >15min = alarm.
|
// Visually escalate as orders age. >5min = highlight; >15min = alarm.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
<q-card-section
|
<q-card-section style="font-size: 1rem">
|
||||||
:style="{'font-size': cookingMode(order) ? '1.25rem' : '1rem'}"
|
|
||||||
>
|
|
||||||
<div v-for="line in order._items || []" :key="line.id">
|
<div v-for="line in order._items || []" :key="line.id">
|
||||||
<div>
|
<div>
|
||||||
<strong v-text="line.quantity + 'x'"></strong>
|
<strong v-text="line.quantity + 'x'"></strong>
|
||||||
|
|
@ -48,7 +46,6 @@
|
||||||
<div
|
<div
|
||||||
v-if="line.selected_modifiers && line.selected_modifiers.length"
|
v-if="line.selected_modifiers && line.selected_modifiers.length"
|
||||||
class="text-caption text-grey-7 q-pl-md"
|
class="text-caption text-grey-7 q-pl-md"
|
||||||
:style="cookingMode(order) ? 'font-size: 1.15rem; font-weight: 500; color: inherit' : ''"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-for="(m, i) in line.selected_modifiers"
|
v-for="(m, i) in line.selected_modifiers"
|
||||||
|
|
@ -61,7 +58,6 @@
|
||||||
<div
|
<div
|
||||||
v-if="line.note"
|
v-if="line.note"
|
||||||
class="text-caption text-amber-9 q-pl-md"
|
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>
|
<q-icon name="info" size="xs"></q-icon>
|
||||||
<span v-text="line.note"></span>
|
<span v-text="line.note"></span>
|
||||||
|
|
|
||||||
63
views_api.py
63
views_api.py
|
|
@ -22,9 +22,8 @@ from pydantic import BaseModel
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from lnbits.core.crud import get_user
|
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.models import Account, WalletTypeInfo
|
||||||
|
from lnbits.core.signers import NostrSigner, resolve_for_wallet
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
check_admin,
|
check_admin,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
|
|
@ -108,53 +107,47 @@ restaurant_api_router = APIRouter()
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_signing_keypair(
|
async def _resolve_signer(
|
||||||
restaurant: Restaurant,
|
restaurant: Restaurant,
|
||||||
) -> Optional[tuple[str, str]]:
|
) -> Optional[NostrSigner]:
|
||||||
"""
|
"""
|
||||||
Resolve the (pubkey, prvkey) pair for signing Nostr events on behalf
|
Resolve a `NostrSigner` for signing events on behalf of a restaurant.
|
||||||
of a restaurant.
|
|
||||||
|
|
||||||
Order of precedence:
|
Order of precedence:
|
||||||
1. restaurant.nostr_pubkey is set → use a per-restaurant key.
|
1. restaurant.nostr_pubkey is set → per-restaurant identity.
|
||||||
(Storage of the corresponding prvkey is intentionally out of
|
(A per-restaurant signer/vault is out of scope here; until
|
||||||
scope here; for now this branch is a no-op until we ship a
|
that lands this branch is a no-op. Returns None.)
|
||||||
secret-management approach. Returns None.)
|
2. Otherwise → fall back to the wallet owner's signer via
|
||||||
2. Otherwise → fall back to the LNbits Account keypair of the
|
`resolve_for_wallet` (wallet → account → signer with a
|
||||||
wallet owner.
|
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:
|
if restaurant.nostr_pubkey:
|
||||||
# TODO: per-restaurant secret key vault.
|
# TODO: per-restaurant signer / secret vault.
|
||||||
return None
|
return None
|
||||||
wallet_obj = await get_wallet(restaurant.wallet)
|
return await resolve_for_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:
|
async def _publish_restaurant(restaurant: Restaurant) -> None:
|
||||||
settings = await get_settings()
|
settings = await get_settings()
|
||||||
if not settings.nostr_publish_enabled:
|
if not settings.nostr_publish_enabled:
|
||||||
return
|
return
|
||||||
keypair = await _resolve_signing_keypair(restaurant)
|
signer = await _resolve_signer(restaurant)
|
||||||
if not keypair:
|
if signer is None:
|
||||||
return
|
return
|
||||||
pubkey, prvkey = keypair
|
|
||||||
|
|
||||||
from . import nostr_client
|
from . import nostr_client
|
||||||
|
|
||||||
event = build_restaurant_metadata_event(restaurant, pubkey)
|
event = build_restaurant_metadata_event(restaurant, signer.pubkey)
|
||||||
published = await publish_event(nostr_client, event, prvkey)
|
published = await publish_event(nostr_client, event, signer)
|
||||||
if published:
|
if published:
|
||||||
restaurant.nostr_event_id = published.id
|
restaurant.nostr_event_id = published.id
|
||||||
restaurant.nostr_event_created_at = published.created_at
|
restaurant.nostr_event_created_at = published.created_at
|
||||||
if not restaurant.nostr_pubkey:
|
if not restaurant.nostr_pubkey:
|
||||||
# Echo back the resolved pubkey so the row carries it for
|
# Echo back the resolved pubkey so the row carries it for
|
||||||
# discovery (e.g. webapp follows this pubkey).
|
# discovery (e.g. webapp follows this pubkey).
|
||||||
restaurant.nostr_pubkey = pubkey
|
restaurant.nostr_pubkey = signer.pubkey
|
||||||
await update_restaurant(restaurant)
|
await update_restaurant(restaurant)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -188,18 +181,17 @@ async def _publish_menu_item(item: MenuItem) -> None:
|
||||||
restaurant = await get_restaurant(item.restaurant_id)
|
restaurant = await get_restaurant(item.restaurant_id)
|
||||||
if not restaurant:
|
if not restaurant:
|
||||||
return
|
return
|
||||||
keypair = await _resolve_signing_keypair(restaurant)
|
signer = await _resolve_signer(restaurant)
|
||||||
if not keypair:
|
if signer is None:
|
||||||
return
|
return
|
||||||
pubkey, prvkey = keypair
|
|
||||||
|
|
||||||
from . import nostr_client
|
from . import nostr_client
|
||||||
|
|
||||||
ancestors = await _ancestor_names_for_node(item.node_id)
|
ancestors = await _ancestor_names_for_node(item.node_id)
|
||||||
event = build_menu_item_event(
|
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:
|
if published:
|
||||||
item.nostr_event_id = published.id
|
item.nostr_event_id = published.id
|
||||||
item.nostr_event_created_at = published.created_at
|
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)
|
restaurant = await get_restaurant(item.restaurant_id)
|
||||||
if not restaurant:
|
if not restaurant:
|
||||||
return
|
return
|
||||||
keypair = await _resolve_signing_keypair(restaurant)
|
signer = await _resolve_signer(restaurant)
|
||||||
if not keypair:
|
if signer is None:
|
||||||
return
|
return
|
||||||
pubkey, prvkey = keypair
|
|
||||||
|
|
||||||
from . import nostr_client
|
from . import nostr_client
|
||||||
|
|
||||||
event = build_delete_event(30402, item.id, pubkey, "Menu item removed")
|
event = build_delete_event(30402, item.id, signer.pubkey, "Menu item removed")
|
||||||
await publish_event(nostr_client, event, prvkey)
|
await publish_event(nostr_client, event, signer)
|
||||||
|
|
||||||
|
|
||||||
def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None:
|
def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue