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>
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.
When a menu item's NIP-99 kind-30402 listing is published, the
extension now emits one 't' tag per ancestor node name (root-first,
slugified to lowercase ASCII). This lets Nostr clients filter the
global listing stream by category — e.g.
{"#t": ["hot-beverages"]}
{"#t": ["coffee-based"]}
without having to know the publisher's pubkey or pull markdown
content. The 'menu' anchor stays first so subscribers can still
get the universal stream. Allergen / ingredient prefixes
(allergen:<x>, ingr:<x>) and dietary tags are unchanged.
nostr_publisher.py:
- Add _slugify(name) -> str (lowercase, [^a-z0-9]+ -> '-', strip).
- build_menu_item_event takes ancestor_names: tuple[str, ...] kw
and emits dedup'd slugs. Stays DB-free; the caller does the
walk.
views_api.py:
- _ancestor_names_for_node walks the materialized path of an
item's node to (root.name, ..., leaf.name).
- _publish_menu_item passes them to the builder.
- api_update_menu_node detects a name change and calls
_republish_subtree_items(node_id), which re-publishes every
menu_item in the subtree so the new ancestor slug lands on
each listing. <=50 items per restaurant in practice; eager
re-publish keeps the relay state consistent without a
background sync.
Remove the transitional layer added in commits 1+2:
models.py
- Drop Category, Subcategory, CreateCategory, CreateSubcategory.
crud.py
- Drop create_category / update_category / get_category /
get_categories / delete_category and the subcategory variants
along with the _node_row_to_category / _node_row_to_subcategory
helpers. Tree state is owned exclusively by menu_node CRUD now.
views_api.py
- Remove old endpoints:
GET /api/v1/restaurants/{id}/categories
POST /api/v1/categories
DELETE /api/v1/categories/{id}
GET /api/v1/categories/{id}/subcategories
POST /api/v1/subcategories
DELETE /api/v1/subcategories/{id}
Hits return 404 now.
- GET /api/v1/restaurants/{id}/menu loses the synthetic
'categories' projection. Response is {restaurant, tree, items}.
static/js/api.js
- Drop listCategories / createCategory / deleteCategory and the
subcategory wrappers.
The CMS menu builder is broken between this commit and commit 4.
The plan acknowledged this trade-off: keeping commits revertible
beats the cost of an unshipped UI page rendering a stale empty
sidebar for one commit's lifetime.
views_api.py:
- New endpoints (admin-key-gated, ownership-checked):
* GET /api/v1/restaurants/{id}/menu_nodes flat list of nodes
* GET /api/v1/menu_nodes/{id} single node
* POST /api/v1/menu_nodes create
* PUT /api/v1/menu_nodes/{id} edit name / desc /
sort_order /
image_url
* PUT /api/v1/menu_nodes/{id}/move body
{new_parent_id}
* DELETE /api/v1/menu_nodes/{id}?cascade=true|false
- ValueError from CRUD (depth, cycle, has-children-without-cascade)
surfaces as 400 (creates / moves) or 409 (delete blocked).
- GET /api/v1/restaurants/{id}/menu now returns three views in
one round trip:
tree: hydrated tree (root nodes -> children + items)
items: flat enriched list (modifiers + availability)
categories: transitional projection of depth-0 nodes with
their immediate items, in the legacy shape — kept
for one commit's lifetime so the existing CMS
keeps rendering. Drops in commit 3.
static/js/api.js:
- listMenuNodes / getMenuNode / createMenuNode / updateMenuNode /
moveMenuNode / deleteMenuNode added.
- Old category/subcategory methods marked transitional in
comments (drop in commit 3).
No JS / template churn — the CMS still reads from menu.categories
which is now produced from menu_nodes via the synthetic projection.
Commit 4 replaces the CMS with q-tree.
Replace the fixed two-level (categories + subcategories) menu shape
with an arbitrary-depth tree, capped at 4 levels. The legacy
Atitlan.io project this carries forward already used a self-FK tree;
real menus need the depth (Drinks -> Hot Beverages -> Coffee-based
-> Espressos). As a side benefit, the current CMS has no UI for
subcategories at all, so this refactor incidentally fixes that gap.
Pattern: adjacency list (parent_id self-FK) + denormalized
materialized path (TEXT, '/'-separated) + denormalized depth.
Rejected closure table (overkill at n=5..50) and Postgres ltree
(not portable to SQLite). Subtree queries become
'WHERE path LIKE :p || '%''; subtree moves are a single
SUBSTR + concat UPDATE; max-depth and cycle checks are O(1).
migrations.py
m002_menu_tree:
- CREATE TABLE menu_nodes (id, restaurant_id, parent_id,
name, description, sort_order, image_url, depth, path, time)
with indexes on (restaurant_id), (parent_id), (path).
- Backfill depth-0 from categories; depth-1 from subcategories
with path = parent.id || '/' || own.id.
- ALTER menu_items ADD COLUMN node_id; backfill via
COALESCE(subcategory_id, category_id). Index on node_id.
- DROP subcategory_id, category_id; DROP TABLE subcategories,
categories.
models.py
- New MAX_MENU_DEPTH = 3 (zero-indexed; 4 levels total).
- New MenuNodeRow (DB I/O shape) + MenuNode (extends with
children + items for hydrated tree responses; never persisted).
- New CreateMenuNode.
- MenuItem.node_id is Optional (orphans allowed when a parent
is deleted with cascade=False); CreateMenuItem.node_id is
required (newly created items must land somewhere).
- Category / Subcategory / Create* kept temporarily as
transitional shim shapes for the old endpoints; dropped in
commit 3.
crud.py
- New: create/update/get/get_all/move/delete_menu_node and
get_menu_tree. move_menu_node uses single-statement subtree
rewrite (path = new_prefix || SUBSTR(path, old_len + 1)).
Cycle check: new_parent's path must not contain node_id.
Depth check: max descendant depth + delta_depth <= MAX_MENU_DEPTH.
delete_menu_node default cascade=False (block on children/items);
cascade=True detaches items (sets node_id NULL) rather than
hard-deletes, since items carry nostr_event_ids and are
revenue-bearing.
- get_menu_tree fetches nodes + items in two queries and assembles
the tree in O(n+m) Python — no recursive CTEs, identical on
SQLite + Postgres.
- Old create_category / get_categories / create_subcategory etc.
rewritten as thin shims that translate to/from menu_nodes.
Old endpoints keep working.
- delete_restaurant cascade now deletes from menu_nodes
(single statement) instead of categories + subcategories.
views_api.py
- GET /restaurants/{id}/menu temporarily sources from menu_nodes
via the shims; surfaces items only at depth-0 nodes for now
(commit 2 replaces the whole block with a real tree response).
static/js/menu.js + templates/restaurant/menu.html
- Rename category_id -> node_id in the item dialog payload so
POST /menu_items satisfies the new CreateMenuItem schema.
The CMS still renders against the depth-0 'categories'
projection; full q-tree rewrite lands in commit 4.