No description
  • Python 72.2%
  • HTML 15.6%
  • JavaScript 12.2%
Find a file
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
docs feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#11) 2026-05-27 22:26:41 +02:00
nostr feat(nostr): NIP-99 menu listings, NIP-01 profile, NIP-17 stub 2026-05-09 07:11:06 +02:00
scripts feat(scripts): add seed_lightning_cafe.py — sats-priced demo restaurant 2026-05-11 23:21:41 +02:00
static fix(cms): KDS card text legible on dark mode 2026-05-11 19:18:33 +02:00
templates/restaurant feat(cms): q-tree menu builder 2026-05-09 07:11:06 +02:00
.gitignore chore: initial extension manifest, config, gitignore 2026-05-09 07:11:06 +02:00
__init__.py feat: extension lifecycle hooks (__init__.py) 2026-05-09 07:11:06 +02:00
config.json chore: initial extension manifest, config, gitignore 2026-05-09 07:11:06 +02:00
crud.py refactor(http): drop categories/subcategories shim 2026-05-09 07:11:06 +02:00
description.md docs: README + description.md 2026-05-09 07:11:06 +02:00
manifest.json chore: initial extension manifest, config, gitignore 2026-05-09 07:11:06 +02:00
migrations.py migrations: make m002_menu_tree idempotent 2026-05-09 07:11:06 +02:00
models.py refactor(http): drop categories/subcategories shim 2026-05-09 07:11:06 +02:00
nostr_publisher.py feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#11) 2026-05-27 22:26:41 +02:00
nostr_sync.py feat(nostr): NIP-99 menu listings, NIP-01 profile, NIP-17 stub 2026-05-09 07:11:06 +02:00
README.md docs: Obsidian-style vault under docs/ 2026-05-09 07:11:06 +02:00
services.py fix(services): convert fiat menu prices to sat via exchange rates 2026-05-11 19:18:16 +02:00
tasks.py feat(services,tasks): order placement, settlement, invoice listener 2026-05-09 07:11:06 +02:00
views.py feat(http): CMS pages + REST API for owners and customers 2026-05-09 07:11:06 +02:00
views_api.py feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#11) 2026-05-27 22:26:41 +02:00

Restaurant — LNbits extension

A Nostr-native restaurant CMS for LNbits. The operator (the person who enables this extension on their LNbits account) builds menus, manages modifiers and inventory, watches orders in real time, and routes paid tickets to a thermal printer. Each restaurant's data is owned by that restaurant's wallet; menus are published to Nostr so any client — from a single venue's customer kiosk to a webapp aggregating dozens of restaurants for a festival — can subscribe and stay live.

What this extension is

A CMS for restaurant operators. One LNbits account can host one or many restaurants under the same login. Each restaurant carries its own profile, an arbitrary-depth menu tree (capped at 4 levels — e.g. Drinks → Hot Beverages → Coffee-based → Espressos) where items can attach to any node (not just leaves), modifier groups (required choices and optional addons, single- or multi-select), per-item availability windows, inventory, and Nostr identity.

A REST API. Public read endpoints serve menu trees and item details; gated write endpoints (admin key) handle CRUD; an unauthenticated order placement endpoint accepts carts and returns a Lightning invoice.

A Nostr publisher. Menu items are published as NIP-99 classified listings (kind 30402, parameterized replaceable) every time they're created or edited; restaurant profiles are kind 0 metadata; deletions are NIP-09. Tags carry structured price, the ancestor category chain (slugified, root-first, so clients can filter on #t=hot-beverages etc.), dietary flags, allergens, and ingredients so subscribers can filter without parsing markdown.

An order pipeline. Every cart placed against this restaurant becomes one order with snapshotted line-item prices and selected modifiers. The invoice listener settles pending → paid on payment; the operator (or auto-accept) walks it through accepted → ready → completed. Stock decrements on settlement, a print job lands in the queue, and the Kitchen Display picks it up.

A single-tenant view of the world. Customer-facing UIs (kiosks, mobile apps, the AIO webapp at ~/dev/webapp) live outside this extension and connect via REST and Nostr. When a customer wants to order across multiple restaurants — at a festival, in a collective space, across a food court — that grouping is curated externally (typically as a NIP-51 list of restaurant pubkeys), the webapp fetches each menu independently, builds a unified cart, and sends one order per restaurant. Each restaurant issues its own bolt11 invoice; the customer pays N invoices to complete the cart. No central wallet holds the float, no splitter divides the payment, and each operator sees their own sats land directly. The webapp pre-flights the total via POST /api/v1/orders/quote so a customer with insufficient balance gets one clean error rather than a partially-paid cart.

Architecture

                         LNbits instance
                ┌───────────────────────────┐
                │  Restaurant ext           │
                │  ├── REST    /restaurant/api/v1
                │  ├── CMS     /restaurant/...
                │  ├── Nostr publisher      │──────┐
                │  └── Invoice listener     │      │
                │       (settle, decrement, │      │
                │        queue print)       │      │
                └─────────┬─────────────────┘      │
                          │                        ▼
                          │              ┌──────────────────┐
                          │              │ nostrclient ext  │──→ relays
                          │              └──────────────────┘
                          ▼
                  ┌──────────────┐         ┌────────────────┐
                  │ printer-pi   │ ◀──────│ webapp / AIO   │
                  │ (subscribes) │         │ (customer UI,  │
                  └──────────────┘         │  multi-rest    │
                                           │  cart)         │
                                           └────────────────┘

A customer's webapp:

  1. Discovers a restaurant (directly, or via a NIP-51 list curated for a festival/collective space).
  2. Loads the menu via GET /api/v1/restaurants/{id}/menu (one-shot tree fetch) and subscribes to the restaurant's Nostr pubkey for live updates.
  3. Builds a cart that may span multiple restaurants.
  4. Calls POST /api/v1/orders/quote (per restaurant) to get the total msat needed; sums them and verifies the wallet has enough.
  5. Calls POST /api/v1/orders once per restaurant; gets back N OrderInvoice payloads ({order_id, payment_hash, bolt11, amount_msat, expires_at}).
  6. Pays each bolt11 from the customer's LNbits wallet.

Each restaurant's LNbits instance:

  1. Receives the payment via its own invoice listener (tag == "restaurant"), looks up the order by payment_hash, transitions the order to paid (or accepted if auto-accept is set), decrements stock, and queues a print job.
  2. Optionally, when wired up, sends NIP-17 status DMs back to the customer's pubkey: paid → preparing → ready.

Data model

Table Purpose
restaurants One row per restaurant. Owns a wallet + Nostr pubkey.
menu_nodes The menu tree. Self-referential (parent_id); carries
denormalized path + depth for cheap subtree ops.
Capped at 4 levels.
menu_items Items keyed to a node (node_id). Structured
dietary / allergens / ingredients, images, stock,
Nostr event id. Items can attach to any node, not
just leaves.
modifier_groups Choice groups (required/optional, one/many).
modifiers Individual options with price_delta.
availability_windows Per-item time-of-day + weekday availability.
orders Per-restaurant order with state machine.
order_items Snapshot of price + selected modifiers at order time.
print_jobs Thermal printer queue with retry tracking.
settings Per-instance toggles (Nostr publish, auto-accept, …).

The menu is an adjacency list with denormalized materialized path: each node has a parent_id self-FK, plus a path TEXT column ('rootid' or 'rootid/childid/...') and an integer depth. This gives cheap subtree queries (WHERE path LIKE :p || '%'), trivial cycle detection on move, and a single-statement subtree path rewrite — identical on SQLite + Postgres, no ltree extension needed. See docs/adr-0001-menu-tree.md for the trade-off vs. a closure table.

Money amounts on orders/order_items are stored as integer msat for precision. Item prices are floats in their declared currency (sat, USD, GTQ, etc.); the order pipeline multiplies by 1000 to go to msat at order time and snapshots that into unit_price_msat.

Order state machine

   pending  ──pay──▶ paid  ──accept──▶ accepted ──ready──▶ ready ──serve──▶ completed
                      │                   │                  │
                      └─cancel────────────┴──────────────────┴─▶ canceled
                      └─refund────────────────────────────────▶ refunded

pending → paid is the only transition driven by money movement (the invoice listener). All others are explicit calls to PUT /api/v1/orders/{id}/status/{new_status} from the CMS.

Nostr

  • Restaurant profile is published as kind 0 (NIP-01 metadata) whenever the restaurant is created or updated.
  • Menu items are published as kind 30402 (NIP-99 classified listings, parameterized replaceable by item.id). Tags: d, title, summary, price (as [price, n, currency]), image*, t (category, dietary, allergen:<x>, ingr:<x>), l (restaurant:<id>), location, g (geohash), status (active/sold).
  • Deletions use kind 5 (NIP-09) referencing the addressable event via ["a", "30402:<pubkey>:<d-tag>"].
  • Inbound order DMs are scaffolded as NIP-17 gift-wrapped DMs (kind 1059). The unwrap step (NIP-44 v2) is a stub; until it lands REST is the supported transport. The dispatcher (_place_order_from_dm) is complete and ready to wire in.

A restaurant signs with restaurant.nostr_pubkey if set (per-restaurant identity), else with the LNbits Account keypair of the wallet owner.

API surface

Reading menus is public (no auth):

  • GET /restaurant/api/v1/restaurants/{id} — profile
  • GET /restaurant/api/v1/restaurants/{id}/menu — full menu tree
  • GET /restaurant/api/v1/menu_items/{id} — single item

Customers placing orders need no auth (the customer pubkey is optional metadata):

  • POST /restaurant/api/v1/orders/quote — pre-flight balance check
  • POST /restaurant/api/v1/orders — place an order, get bolt11
  • GET /restaurant/api/v1/orders/{id} — order + items

Owners write with their wallet's admin key:

  • POST /restaurant/api/v1/restaurants — create
  • PUT /restaurant/api/v1/restaurants/{id} — update
  • POST /restaurant/api/v1/menu_items — create
  • PUT /restaurant/api/v1/menu_items/{id} — update (re-publishes to Nostr; kind 30402 is replaceable)
  • DELETE /restaurant/api/v1/menu_items/{id} — delete (sends NIP-09 deletion to Nostr)
  • PUT /restaurant/api/v1/orders/{id}/status/{new_status} — manual state transitions
  • PUT /restaurant/api/v1/print_jobs/{id}/ack — printer-pi acknowledgement

Plus full CRUD for menu nodes (the tree), modifier groups, modifiers, and availability windows. Menu node operations:

  • POST /restaurant/api/v1/menu_nodes — create (depth + path derived from parent; rejected at depth > 4)
  • PUT /restaurant/api/v1/menu_nodes/{id} — rename / desc / sort / image. Rename re-publishes every item in the subtree so their ancestor t tags update on Nostr.
  • PUT /restaurant/api/v1/menu_nodes/{id}/move — body {new_parent_id}; single-statement subtree rewrite, cycle-checked
  • DELETE /restaurant/api/v1/menu_nodes/{id}?cascade=true|false — default blocks if the node has children or items; cascade deletes the subtree and detaches items (sets node_id to null) rather than wiping them, since items carry nostr_event_ids and revenue history.

Customer-facing webapp integration

The webapp's multi-restaurant cart flow:

// 1. Resolve restaurant pubkeys (e.g. from a NIP-51 festival list).
const restaurants = await fetchRestaurantsForFestival(festivalId)

// 2. Pull each menu in parallel; subscribe to Nostr for live updates.
await Promise.all(restaurants.map(r =>
  fetch(`/restaurant/api/v1/restaurants/${r.id}/menu`).then(r => r.json())
))

// 3. User builds a cart spanning N restaurants.
//    Group lines by restaurant_id.
const cartByRestaurant = groupBy(cart.lines, line => line.restaurant_id)

// 4. Pre-flight: ask each restaurant for the msat total.
const quotes = await Promise.all(
  Object.entries(cartByRestaurant).map(([rid, lines]) =>
    fetch(`/restaurant/api/v1/orders/quote`, {
      method: 'POST',
      body: JSON.stringify(lines.map(l => ({
        menu_item_id: l.id,
        quantity: l.qty,
        selected_modifiers: l.modifiers
      })))
    }).then(r => r.json()).then(j => ({rid, lines, msat: j.required_msat}))
  )
)

const totalMsat = quotes.reduce((s, q) => s + q.msat, 0)
if (walletBalanceMsat < totalMsat) {
  alert('Insufficient balance — top up first')
  return
}

// 5. Open one order per restaurant. Each returns its own bolt11.
const orders = []
for (const q of quotes) {
  const res = await fetch(`/restaurant/api/v1/orders`, {
    method: 'POST',
    body: JSON.stringify({
      restaurant_id: q.rid,
      items: q.lines.map(...),
      customer_pubkey: window.user.nostrPubkey,
      parent_order_ref: cart.id,
      tip_msat: q.tipMsat,
      payment_method: 'lightning'
    })
  }).then(r => r.json())
  orders.push(res)
}

// 6. Pay each bolt11 in sequence from the user's wallet.
//    The restaurant's invoice listener marks each as paid + queues
//    its print job independently.
for (const o of orders) {
  await payInvoice(o.invoice.bolt11)
}

Install (dev)

cd ~/dev/lnbits/main
# Drop a clone of this repo into the extensions dir LNbits expects:
ln -s ~/dev/shared/extensions/restaurant lnbits/extensions/restaurant
poetry run lnbits           # or whatever your dev runner is

Then enable the extension from the LNbits admin UI. The nostrclient extension must also be enabled for the publisher and sync to function — without it, the extension still works, just without the Nostr layer.

Roadmap (not implemented yet)

  • NIP-44 v2 unwrap for NIP-17 gift-wrapped order DMs.
  • Per-restaurant Nostr keypair secret storage (currently the fallback to the LNbits Account keypair is the only working path).
  • SSE / push for orders + KDS (today the CMS polls every 58 s).
  • HODL invoices for atomic multi-restaurant cart settlement (the scaffold accepts that best-effort sequential payment is enough for internal LNbits transfers; HODL would harden the rare external case).
  • Foreign menu cache so a single LNbits instance can serve a webapp that aggregates restaurants from many other instances. The nostr_sync skeleton currently only echoes our own published items.
  • Image upload pipeline (today images are URLs; a CDN integration belongs in the AIO webapp, not here).

Documentation

Deeper docs live in docs/ as an Obsidian-style vault. Start at docs/index.md (Map of Content) and follow the [[wikilinks]]. Highlights:

License

MIT.