From 5b176e5186f0ce39730c66c5b67bdd59c485c026 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 01:30:13 +0200 Subject: [PATCH] feat: expose Chatelet over the LNbits nostr transport (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- __init__.py | 57 +++++++++++-- docs/event-flow.md | 42 +++++++++- transport_rpcs.py | 201 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 transport_rpcs.py diff --git a/__init__.py b/__init__.py index c18179a..910eb39 100644 --- a/__init__.py +++ b/__init__.py @@ -35,12 +35,57 @@ def chatelet_start(): scheduled_tasks.append( 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: - # from .nostr.service import subscribe_inbound - # scheduled_tasks.append( - # create_permanent_unique_task("chatelet_nostr_in", subscribe_inbound) - # ) + + # Expose the booking flow over the core LNbits nostr transport (kind-21000 + # RPC) so an HTTP-allergic client can drive Chatelet over relays. Also wire + # the booking-owner resolver so the operator can stream booking + # settlements via subscribe_payments({tag:"chatelet", link_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__ = [ diff --git a/docs/event-flow.md b/docs/event-flow.md index 3355acb..5989195 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -7,9 +7,45 @@ How rooms, guests, LNbits, and relays interact. The guiding rule: > Lightning payment is the confirmation. Two doors lead into the *same* booking flow — the REST API -([`../views_api.py`](../views_api.py)) and the Nostr subscription -([`../nostr/service.py`](../nostr/service.py)). Both funnel through -[`../crud.py`](../crud.py) so arbitration + quoting live in one place. +([`../views_api.py`](../views_api.py)) and the Nostr-transport RPC layer +([`../transport_rpcs.py`](../transport_rpcs.py)). Both delegate to +[`../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:})`, 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 diff --git a/transport_rpcs.py b/transport_rpcs.py new file mode 100644 index 0000000..f8f634b --- /dev/null +++ b/transport_rpcs.py @@ -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:}). +""" + +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