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:
parent
5cfc9b893d
commit
5b176e5186
3 changed files with 291 additions and 9 deletions
201
transport_rpcs.py
Normal file
201
transport_rpcs.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue