Closesaiolabs/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>
The age-escalation highlights (bg-amber-1 / bg-orange-1 /
bg-red-1) are very pale Quasar shades. On LNbits dark mode the
q-card inherits a near-white text color from the theme — paired
with the pale background that's white-on-cream, which is what
the user reported: the '1x Coffee' on a ready-card was barely
visible.
Pin an explicit text-grey-9 alongside each pale bg so dark text
on light background renders in both themes. The 'no highlight'
branch returns '' unchanged, so non-aged orders still use the
q-card's theme-aware default text color.
Before this fix `_to_msat(item.price)` blindly did `price * 1000`,
treating any menu price as sat-denominated regardless of the item's
`currency` field. Quote and bolt11 were internally consistent but
charged ~0.1% of the real price for fiat-priced menus.
Big Jay's seeded with GTQ:
2× Tacos (Maíz, +Brisket, +Chicken)
pre-fix: 170 GTQ → 170000 msat → 170 sat invoice (~$0.14)
post-fix: 170 GTQ → 26968000 msat → 26968 sat invoice (~$22)
services.py:
- Drop `_to_msat` in favor of a `_price_to_msat(amount, currency)`
helper. Sat-aliased currencies ("sat", "sats", "satoshi",
"msat", …) take the flat ×1000 path; everything else round-
trips through lnbits.utils.exchange_rates.fiat_amount_as_satoshis
(same pool the events extension uses).
- Update _price_line_item: item.price AND each modifier.price_delta
are converted using item.currency. Modifier deltas inherit the
parent item's currency since we don't carry a per-modifier
currency field.
- Update quote_balance_required: same conversion via the item's
currency.
Verified live against the seeded "Big Jay's Bustaurant":
GTQ → sat conversion matches LNbits's bitcoin-price aggregate
(Binance / Blockchain / Bitfinex / Bitstamp / Coinbase / yadio).
Quote returns 26968 sats for 170 GTQ — within ~2% of expected
rate from external sources.
Prerequisite for the customer webapp module (aiolabs/webapp,
branch feat/restaurant-bundle): the webapp's /r/:slug route needs
to resolve a slug to a Restaurant payload without an admin key.
crud.get_restaurant_by_slug already exists (used by the server-
rendered CMS routes in views.py); just expose it as a public REST
endpoint. Mirrors api_get_restaurant by id and is declared before
the bare-id route so the static prefix wins FastAPI's path match.
Verified live against seeded 'Big Jay's Bustaurant':
GET /restaurant/api/v1/restaurants/by-slug/big-jays-bustaurant
-> 200 with the Restaurant payload.
The castle LNbits extension was renamed to libra. Updating the
parallel-extension reference in the design-conversation notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a navigable Obsidian vault as the project's first-class
technical documentation. Notes cross-reference with [[wikilinks]];
docs/index.md is the Map of Content.
New notes:
index.md MOC, entry point
architecture.md what the extension owns vs what lives outside
data-model.md entity-by-entity schema reference
menu-tree.md the arbitrary-depth tree concept
order-flow.md state machine + invoice listener + print
nostr-layer.md kinds 0/30402/5/1059, signing, t-tags
api-reference.md endpoint catalog by audience
cms.md Vue 3 + Quasar 2 UMD conventions, q-tree
webapp-integration.md multi-restaurant cart pattern + atomicity
glossary.md domain terms
Existing notes (kept as-is):
adr-0001-menu-tree.md the storage choice rationale
design-conversation.md trimmed transcript
README.md adds a Documentation section pointing at docs/index.md
with the headline note list. Each note links to ~3-5 others; the
vault forms a connected graph.
A project-level memory rule (saved outside the repo) commits us to
keeping these docs in sync as the code evolves: any commit that
materially changes schema, API, order flow, Nostr surface, CMS
conventions, or webapp integration must update the relevant note(s)
in the same commit.
README.md
- Update intro: 'menu tree' is now arbitrary-depth (cap 4
levels), items can attach to any node.
- Update Nostr publisher description to mention ancestor 't'
tags (slugified, root-first) so clients can filter on
#t=hot-beverages, #t=coffee-based, etc.
- Replace the Data model table's categories/subcategories rows
with a single menu_nodes row that explains the adjacency-list
+ materialized-path + depth shape and points at the ADR.
- Replace the boilerplate 'full CRUD for categories,
subcategories, ...' line with a real menu_nodes API list,
including the cascade-detach behavior on delete and the
rename-triggers-subtree-republish behavior on update.
docs/adr-0001-menu-tree.md
- New ADR explaining the storage choice (adjacency list +
materialized path + denormalized depth), the alternatives
considered (closure table, Postgres ltree, pure adjacency,
nested set), and the consequences. Provides the rationale
so future contributors don't relitigate the decision.
Trimmed render of the Claude Code session that produced the initial
scaffold. Tool calls, agent sub-conversations, system reminders, and
diagnostics stripped; only user prompts + assistant prose remain
(31 user / 30 assistant turns, ~32 KB).
Captures the design rationale that doesn't otherwise live in commit
messages:
- why per-restaurant invoices instead of split settlement (each
item linked to its restaurant; each restaurant issues its own
bolt11; webapp pre-flights total balance before paying any)
- why 'festival' is not an entity in the data model (curated
NIP-51 lists are emergent from outside the extension)
- why menus are NIP-99 listings (parameterized replaceable; tags
carry price, dietary, allergens, ingredients)
- why the customer kiosk lives in ~/dev/webapp, not in this
extension's static dir
- the 'nostrize everything' direction from the OmniXY stack
overview, and how it shapes the publisher/sync split here