feat: expose Chatelet over the LNbits nostr transport (#1)

Register kind-21000 RPC handlers on the core nostr_transport dispatcher so
the booking flow runs over relays with no HTTP, mirroring lnurlp:

- operator (AUTH_WALLET): room create/update/publish, block create — all
  ownership-checked; room_list_mine (AUTH_ACCOUNT).
- public (AUTH_NONE): room_list/get (wallet id stripped), availability,
  booking_request, booking_get. Guest identity is the signed sender_pubkey,
  so no guest_pubkey is trusted from the body.
- register_link_owner_resolver(tag=chatelet, key=booking_id) lets the
  operator stream settlements via subscribe_payments.

Handlers delegate to services.py — no logic duplicated. Graceful no-op if
the core transport module isn't in this LNbits build (pre-#4). Guests can't
subscribe to the operator wallet, so they poll booking_get to confirm.

Note documented in event-flow.md: with availability now an RPC, the custom
kind:22000/22001 pair is redundant for RPC clients (revisit in #2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
Padreug 2026-07-19 01:30:13 +02:00
commit 5b176e5186
3 changed files with 291 additions and 9 deletions

View file

@ -35,12 +35,57 @@ def chatelet_start():
scheduled_tasks.append( scheduled_tasks.append(
create_permanent_unique_task("chatelet_hold_expiry", expire_holds_loop) create_permanent_unique_task("chatelet_hold_expiry", expire_holds_loop)
) )
# TODO(relay): once relay plumbing lands, also start the inbound Nostr
# subscription so guests can query availability + book over Nostr: # Expose the booking flow over the core LNbits nostr transport (kind-21000
# from .nostr.service import subscribe_inbound # RPC) so an HTTP-allergic client can drive Chatelet over relays. Also wire
# scheduled_tasks.append( # the booking-owner resolver so the operator can stream booking
# create_permanent_unique_task("chatelet_nostr_in", subscribe_inbound) # settlements via subscribe_payments({tag:"chatelet", link_id:<booking_id>}).
# ) # No-op if the core transport module isn't present in this LNbits build.
try:
from lnbits.core.services.nostr_transport.dispatcher import (
AUTH_ACCOUNT,
AUTH_NONE,
AUTH_WALLET,
register_rpc,
)
from lnbits.core.services.nostr_transport.subscriptions import (
register_link_owner_resolver,
)
except ImportError:
return
from .transport_rpcs import (
handle_availability,
handle_block_create,
handle_booking_get,
handle_booking_request,
handle_room_create,
handle_room_get,
handle_room_list,
handle_room_list_mine,
handle_room_publish,
handle_room_update,
resolve_chatelet_owner,
)
# operator (wallet-scoped)
register_rpc("chatelet_room_create", handle_room_create, AUTH_WALLET)
register_rpc("chatelet_room_update", handle_room_update, AUTH_WALLET)
register_rpc("chatelet_room_publish", handle_room_publish, AUTH_WALLET)
register_rpc("chatelet_block_create", handle_block_create, AUTH_WALLET)
register_rpc("chatelet_room_list_mine", handle_room_list_mine, AUTH_ACCOUNT)
# public (discovery + guest booking)
register_rpc("chatelet_room_list", handle_room_list, AUTH_NONE)
register_rpc("chatelet_room_get", handle_room_get, AUTH_NONE)
register_rpc("chatelet_availability", handle_availability, AUTH_NONE)
register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE)
register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE)
# tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid),
# so override the default link_extra_key ("link") to match.
register_link_owner_resolver(
"chatelet", resolve_chatelet_owner, link_extra_key="booking_id"
)
__all__ = [ __all__ = [

View file

@ -7,9 +7,45 @@ How rooms, guests, LNbits, and relays interact. The guiding rule:
> Lightning payment is the confirmation. > Lightning payment is the confirmation.
Two doors lead into the *same* booking flow — the REST API Two doors lead into the *same* booking flow — the REST API
([`../views_api.py`](../views_api.py)) and the Nostr subscription ([`../views_api.py`](../views_api.py)) and the Nostr-transport RPC layer
([`../nostr/service.py`](../nostr/service.py)). Both funnel through ([`../transport_rpcs.py`](../transport_rpcs.py)). Both delegate to
[`../crud.py`](../crud.py) so arbitration + quoting live in one place. [`../services.py`](../services.py) (orchestration) over
[`../crud.py`](../crud.py) (persistence) so arbitration + quoting live in one
place and can't drift between doors.
## Door 2: RPC over the core nostr transport
The core LNbits nostr transport (`lnbits.core.services.nostr_transport`) is a
**kind-21000 encrypted RPC bus** (NIP-44), not a general event publisher.
Chatelet registers handlers on it in `chatelet_start()` so the whole booking
flow runs over relays with no HTTP:
| RPC | Auth | Purpose |
|---|---|---|
| `chatelet_room_create` / `_update` / `_publish` | wallet | operator room CRUD (ownership-checked) |
| `chatelet_block_create` | wallet | operator blocks a range |
| `chatelet_room_list_mine` | account | operator's rooms across their wallets |
| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped) |
| `chatelet_availability` | none | is a range free + a quote |
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) |
| `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) |
Guest identity is the `sender_pubkey` the dispatcher lifts off the signed
kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is
trusted from the body.
**Payment confirmation over RPC:** `subscribe_payments` is wallet-owner
scoped, so the *operator* can stream booking settlements
(`subscribe_payments({tag:"chatelet", link_id:<booking_id>})`, wired via
`register_link_owner_resolver`). A **guest** can't subscribe to the operator's
wallet, so the guest confirms by polling `chatelet_booking_get` until
`confirmed` (a guest push would need a NIP-17 DM — issue #5).
> **Consequence for the custom kinds:** with availability answered by the
> `chatelet_availability` RPC, the ephemeral `kind:22000/22001` availability
> query/response (ADR-0001) is **redundant for RPC clients**. It is only worth
> keeping if we want non-RPC Nostr clients to query availability by publishing
> an event. Revisit when the public-discovery transport is chosen (issue #2).
## Actors ## Actors

201
transport_rpcs.py Normal file
View file

@ -0,0 +1,201 @@
"""Nostr-transport RPC handlers for Chatelet.
Exposes the same booking flow as views_api.py, but encrypted over kind-21000
events through the core LNbits nostr transport. Both doors delegate to
services.py no logic is duplicated here. Mirrors lnurlp/transport_rpcs.py.
Auth model (set by the registrations in __init__.py:chatelet_start):
- room create/update/publish, block create AUTH_WALLET. The transport
resolves the caller's pubkey to a wallet; handlers read auth.wallet.id and
enforce room ownership (room.wallet == caller's wallet).
- room list-mine AUTH_ACCOUNT (operator lists rooms across their wallets).
- availability, room list/get, booking request/get AUTH_NONE (public;
guests are external Nostr users with no LNbits wallet). The guest's Nostr
identity arrives as request.sender_pubkey no separate guest_pubkey needed.
The guest can't stream the operator's wallet via subscribe_payments (that's
wallet-owner-scoped), so a guest confirms a booking by polling
chatelet_booking_get. resolve_chatelet_owner lets the OPERATOR subscribe to
their booking settlements: subscribe_payments({tag:"chatelet", link_id:<booking_id>}).
"""
from __future__ import annotations
import json
from lnbits.core.crud.wallets import get_wallets
from lnbits.core.models import Account
from lnbits.core.models.wallets import WalletTypeInfo
from lnbits.core.services.nostr_transport.models import NostrRpcRequest
from . import crud, services
from .models import BookingRequestData, CreateBlockData, CreateRoomData, RoomStatus
# Fields a client may patch on a room via chatelet_room_update. Identity /
# counter fields (id, wallet, listing_event_id, created_at) are not mutable;
# status flips through chatelet_room_publish, not here.
_MUTABLE_ROOM = {
"title",
"description",
"price_amount",
"price_currency",
"price_frequency",
"max_guests",
"min_nights",
"amenities",
"location",
"geohash",
"images",
}
# --- operator: rooms (AUTH_WALLET) -----------------------------------------
async def handle_room_create(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
body = request.body or {}
body["wallet"] = auth.wallet.id # always create under the calling wallet
room = await crud.create_room(CreateRoomData(**body))
return _to_dict(room)
async def handle_room_update(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
room = await _require_owned_room(_require_id(request), auth.wallet.id)
for k, v in (request.body or {}).items():
if k in _MUTABLE_ROOM:
setattr(room, k, v)
return _to_dict(await crud.update_room(room))
async def handle_room_publish(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
from .nostr import service as nostr
room = await _require_owned_room(_require_id(request), auth.wallet.id)
room.status = RoomStatus.active
room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id
return _to_dict(await crud.update_room(room))
async def handle_room_list_mine(auth: Account, request: NostrRpcRequest) -> list[dict]:
"""All rooms across wallets owned by the calling account."""
wallet_ids = {w.id for w in await get_wallets(auth.id)}
rooms = await crud.get_rooms()
return [_to_dict(r) for r in rooms if r.wallet in wallet_ids]
# --- operator: blocks (AUTH_WALLET) ----------------------------------------
async def handle_block_create(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
from .nostr import service as nostr
body = request.body or {}
room = await _require_owned_room(str(body.get("room_id", "")), auth.wallet.id)
block = await crud.create_block(CreateBlockData(**body))
await nostr.publish_block_calendar(
room, block.start_date, block.end_date, block.id
)
return _to_dict(block)
# --- public: discovery + booking (AUTH_NONE) -------------------------------
async def handle_room_list(auth: None, request: NostrRpcRequest) -> list[dict]:
"""Active rooms only, wallet id stripped (public discovery)."""
rooms = await crud.get_rooms()
return [_public_room(r) for r in rooms if r.status == RoomStatus.active]
async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict:
room = await crud.get_room(_require_id(request))
if not room or room.status != RoomStatus.active:
raise ValueError("Room not available")
return _public_room(room)
async def handle_availability(auth: None, request: NostrRpcRequest) -> dict:
body = request.body or {}
result = await services.get_availability(
_require(body, "room_id"),
_require(body, "check_in"),
_require(body, "check_out"),
)
return _to_dict(result)
async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict:
"""Guest requests a stay. The guest's identity is request.sender_pubkey
(from the signed kind-21000 event) clients cannot spoof it."""
sender = request.sender_pubkey
if not sender:
raise PermissionError("booking request: missing sender pubkey")
body = request.body or {}
data = BookingRequestData(
room_id=_require(body, "room_id"),
guest_pubkey=sender,
check_in=_require(body, "check_in"),
check_out=_require(body, "check_out"),
num_guests=body.get("num_guests", 1),
guest_contact=body.get("guest_contact"),
message=body.get("message"),
)
quote = await services.request_booking(data)
return _to_dict(quote)
async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict:
booking = await crud.get_booking(_require_id(request))
if not booking:
raise ValueError("Booking not found")
# Ownership: only the guest who made it can read it back.
if booking.guest_pubkey != request.sender_pubkey:
raise PermissionError("booking does not belong to caller")
return _to_dict(booking)
# --- subscription resolver -------------------------------------------------
async def resolve_chatelet_owner(booking_id: str) -> str | None:
"""For the core subscription module: booking_id -> owning wallet id, so
the operator can subscribe_payments({tag:"chatelet", link_id:booking_id})."""
booking = await crud.get_booking(booking_id)
if not booking:
return None
room = await crud.get_room(booking.room_id)
return room.wallet if room else None
# --- helpers ---------------------------------------------------------------
def _require(body: dict, field: str) -> str:
val = body.get(field)
if not val:
raise ValueError(f"chatelet: body.{field} is required")
return str(val)
def _require_id(request: NostrRpcRequest) -> str:
return _require(request.body or {}, "id")
async def _require_owned_room(room_id: str, wallet_id: str):
room = await crud.get_room(room_id)
if room is None:
raise ValueError(f"chatelet: room not found: {room_id}")
if room.wallet != wallet_id:
raise PermissionError("chatelet: room does not belong to caller's wallet")
return room
def _to_dict(obj) -> dict:
return json.loads(obj.json())
def _public_room(room) -> dict:
d = _to_dict(room)
d.pop("wallet", None) # wallet id is operator-internal, not for guests
return d