From 5cfc9b893d7289e717a62994b50aad606ca26e00 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 01:30:13 +0200 Subject: [PATCH 1/3] refactor: extract booking flow into services.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move availability quoting + the check-then-hold + invoice orchestration out of views_api.py into a transport-agnostic services.py, with typed errors (NotFound/Unavailable/ValueError/BookingError). views_api becomes a thin HTTP door that maps those to status codes. No behavior change — this is so the incoming nostr-transport door can share one booking flow instead of duplicating it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- services.py | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++ views_api.py | 144 +++++++++---------------------------------------- 2 files changed, 171 insertions(+), 120 deletions(-) create mode 100644 services.py diff --git a/services.py b/services.py new file mode 100644 index 0000000..e0a12ae --- /dev/null +++ b/services.py @@ -0,0 +1,147 @@ +"""Booking orchestration shared by both entry points. + +`views_api.py` (HTTP) and `transport_rpcs.py` (Nostr kind-21000 RPC) are two +doors into the same flow; the availability arbiter + quoting + hold + invoice +logic lives here so neither door duplicates it and they can't drift. `crud.py` +is persistence; this module is the flow on top of it. + +Exceptions are typed so each door can map them to its own error surface (HTTP +status / RPC error). All the client-facing ones subclass `ValueError` so the +Nostr dispatcher — which turns `ValueError`/`PermissionError` into a returned +error message — relays them to the caller verbatim; `BookingError` is a +backend failure and surfaces as a generic error over RPC (logged server-side). +""" + +from datetime import datetime, timedelta, timezone + +from lnbits.core.services import create_invoice +from lnbits.exceptions import InvoiceError +from lnbits.helpers import urlsafe_short_hash +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis + +from . import crud +from .models import ( + AvailabilityResult, + Booking, + BookingQuote, + BookingRequestData, + BookingStatus, + RoomStatus, +) + + +class NotFound(ValueError): + """Referenced entity does not exist (-> HTTP 404).""" + + +class Unavailable(ValueError): + """Room inactive or dates already taken (-> HTTP 409).""" + + +class BookingError(Exception): + """Backend failure mid-booking, e.g. invoice creation (-> HTTP 502).""" + + +async def to_sats(amount: float, currency: str) -> int: + """Fiat/currency -> sats. The single FX point; its result is the canonical + amount_sat and is never recomputed downstream (source-of-truth rule).""" + if currency.lower() in ("sat", "sats"): + return int(amount) + return await fiat_amount_as_satoshis(amount, currency) + + +async def get_availability( + room_id: str, check_in: str, check_out: str +) -> AvailabilityResult: + room = await crud.get_room(room_id) + if not room: + raise NotFound("Room not found") + nights = crud.nights_between(check_in, check_out) + if nights < 1: + raise ValueError("check_out must be after check_in") + available = await crud.is_available(room_id, check_in, check_out) + quote_sat = quote_fiat = None + if available: + quote_fiat = round(room.price_amount * nights, 2) + quote_sat = await to_sats(quote_fiat, room.price_currency) + return AvailabilityResult( + room_id=room_id, + check_in=check_in, + check_out=check_out, + available=available, + nights=nights, + quote_sat=quote_sat, + quote_fiat=quote_fiat, + currency=room.price_currency, + ) + + +async def request_booking(data: BookingRequestData) -> BookingQuote: + """Check-then-hold, then invoice. The `is_available` read + the `held` + write are the lock; TODO(#4) makes that pair atomic against a concurrent + request. Returns the held booking + the bolt11 that will confirm it.""" + room = await crud.get_room(data.room_id) + if not room or room.status != RoomStatus.active: + raise NotFound("Room not available") + + nights = crud.nights_between(data.check_in, data.check_out) + if nights < room.min_nights: + raise ValueError(f"Minimum stay is {room.min_nights} night(s)") + if data.num_guests > room.max_guests: + raise ValueError(f"Max {room.max_guests} guests") + + # TODO(#4): wrap is_available + create_booking in a per-room lock / txn. + if not await crud.is_available(data.room_id, data.check_in, data.check_out): + raise Unavailable("Those dates are no longer available") + + settings = await crud.get_or_create_settings() + price_fiat = round(room.price_amount * nights, 2) + amount_sat = await to_sats(price_fiat, room.price_currency) # canonical + deposit_sat = amount_sat * settings.deposit_percent // 100 + + booking = Booking( + id=urlsafe_short_hash()[:10], + room_id=room.id, + guest_pubkey=data.guest_pubkey, + guest_contact=data.guest_contact, + check_in=data.check_in, + check_out=data.check_out, + nights=nights, + num_guests=data.num_guests, + currency=room.price_currency, + price_fiat=price_fiat, + amount_sat=amount_sat, + deposit_sat=deposit_sat, + status=BookingStatus.held, + expires_at=datetime.now(timezone.utc) + + timedelta(minutes=settings.default_hold_minutes), + ) + await crud.create_booking(booking) + + # Sats-denominated (deposit_sat locked at quote time) so FX drift before + # payment can't change what's owed. tag+booking_id let + # tasks.on_invoice_paid match the settlement back to this booking. + try: + payment = await create_invoice( + wallet_id=room.wallet, + amount=booking.deposit_sat, + memo=( + f"Chatelet · {room.title} · " + f"{booking.check_in}→{booking.check_out} ({nights}n)" + ), + extra={"tag": "chatelet", "booking_id": booking.id}, + ) + except InvoiceError as exc: + booking.status = BookingStatus.declined # dead hold -> free the dates + await crud.update_booking(booking) + raise BookingError(f"Could not create invoice: {exc.message}") from exc + + booking.payment_hash = payment.payment_hash + booking.status = BookingStatus.awaiting_payment + await crud.update_booking(booking) + + return BookingQuote( + booking=booking, + payment_request=payment.bolt11, + payment_hash=payment.payment_hash, + ) diff --git a/views_api.py b/views_api.py index 20ec9a1..91ec6b9 100644 --- a/views_api.py +++ b/views_api.py @@ -1,33 +1,23 @@ """Chatelet REST API. -The REST surface and the Nostr surface (nostr/service.py) are two doors -into the SAME booking flow — both funnel through crud + the helpers here so -availability arbitration and quoting live in exactly one place. Per the -aiolabs long-term direction (webapp<->lnbits over Nostr), REST is the -transitional door; don't grow booking logic that only the HTTP path knows. - -SKETCH: handlers show the intended flow and auth scopes; the pricing/FX and -invoice-creation calls are marked TODO where they'd touch core LNbits. +The REST surface and the Nostr-transport surface (transport_rpcs.py) are two +doors into the SAME booking flow — both delegate to services.py so +availability arbitration + quoting live in one place. Per the aiolabs +long-term direction (webapp<->lnbits over Nostr), REST is the transitional +door; keep new booking logic in services.py, not here. """ -from datetime import datetime, timedelta, timezone - from fastapi import APIRouter, Depends, HTTPException from lnbits.core.models import WalletTypeInfo -from lnbits.core.services import create_invoice from lnbits.decorators import require_admin_key, require_invoice_key -from lnbits.exceptions import InvoiceError -from lnbits.helpers import urlsafe_short_hash -from lnbits.utils.exchange_rates import fiat_amount_as_satoshis -from . import crud +from . import crud, services from .models import ( AvailabilityQuery, AvailabilityResult, Booking, BookingQuote, BookingRequestData, - BookingStatus, CreateBlockData, CreateRoomData, Room, @@ -37,6 +27,15 @@ from .nostr import service as nostr chatelet_api_router = APIRouter() +def _to_http(exc: ValueError) -> HTTPException: + """Map a services-layer ValueError subclass to an HTTP status.""" + if isinstance(exc, services.NotFound): + return HTTPException(404, str(exc)) + if isinstance(exc, services.Unavailable): + return HTTPException(409, str(exc)) + return HTTPException(400, str(exc)) + + # --- rooms (operator; admin-key scoped to own wallet) ---------------------- @@ -70,27 +69,10 @@ async def api_publish_room( @chatelet_api_router.post("/api/v1/availability") async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: - room = await crud.get_room(q.room_id) - if not room: - raise HTTPException(404, "Room not found") - nights = crud.nights_between(q.check_in, q.check_out) - if nights < 1: - raise HTTPException(400, "check_out must be after check_in") - available = await crud.is_available(q.room_id, q.check_in, q.check_out) - quote_sat = quote_fiat = None - if available: - quote_fiat = round(room.price_amount * nights, 2) - quote_sat = await _to_sats(quote_fiat, room.price_currency) - return AvailabilityResult( - room_id=q.room_id, - check_in=q.check_in, - check_out=q.check_out, - available=available, - nights=nights, - quote_sat=quote_sat, - quote_fiat=quote_fiat, - currency=room.price_currency, - ) + try: + return await services.get_availability(q.room_id, q.check_in, q.check_out) + except ValueError as exc: + raise _to_http(exc) from exc # --- booking (public write; guest-initiated) ------------------------------- @@ -98,78 +80,12 @@ async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: @chatelet_api_router.post("/api/v1/bookings", status_code=201) async def api_request_booking(data: BookingRequestData) -> BookingQuote: - """Guest requests a stay. Check-then-hold is the lock: if available we - write a `held` booking with the canonical amount_sat, create the deposit - invoice, and return the bolt11. The Nostr path calls the same steps.""" - room = await crud.get_room(data.room_id) - if not room or room.status != room.status.active: - raise HTTPException(404, "Room not available") - - nights = crud.nights_between(data.check_in, data.check_out) - if nights < room.min_nights: - raise HTTPException(400, f"Minimum stay is {room.min_nights} night(s)") - if data.num_guests > room.max_guests: - raise HTTPException(400, f"Max {room.max_guests} guests") - - # TODO(concurrency): wrap is_available + create_booking in a per-room - # lock / DB transaction so two requests can't both pass the read. - if not await crud.is_available(data.room_id, data.check_in, data.check_out): - raise HTTPException(409, "Those dates are no longer available") - - settings = await crud.get_or_create_settings() - price_fiat = round(room.price_amount * nights, 2) - amount_sat = await _to_sats(price_fiat, room.price_currency) # canonical - deposit_sat = amount_sat * settings.deposit_percent // 100 - - booking = Booking( - id=urlsafe_short_hash()[:10], - room_id=room.id, - guest_pubkey=data.guest_pubkey, - guest_contact=data.guest_contact, - check_in=data.check_in, - check_out=data.check_out, - nights=nights, - num_guests=data.num_guests, - currency=room.price_currency, - price_fiat=price_fiat, - amount_sat=amount_sat, - deposit_sat=deposit_sat, - status=BookingStatus.held, - expires_at=datetime.now(timezone.utc) - + timedelta(minutes=settings.default_hold_minutes), - ) - await crud.create_booking(booking) - - # Invoice is denominated in sats (deposit_sat is already the canonical - # amount locked at quote time) so FX drift between now and payment can't - # change what the guest owes. tag+booking_id let tasks.on_invoice_paid - # match the settlement back to this booking. try: - payment = await create_invoice( - wallet_id=room.wallet, - amount=booking.deposit_sat, - memo=( - f"Chatelet · {room.title} · " - f"{booking.check_in}→{booking.check_out} ({nights}n)" - ), - extra={"tag": "chatelet", "booking_id": booking.id}, - ) - except InvoiceError as exc: - # Free the dates immediately — a hold with no payable invoice is dead. - booking.status = BookingStatus.declined - await crud.update_booking(booking) - raise HTTPException(502, f"Could not create invoice: {exc.message}") from exc - - booking.payment_hash = payment.payment_hash - booking.status = BookingStatus.awaiting_payment - await crud.update_booking(booking) - - # TODO(#5): DM the bolt11 to the guest over NIP-17 for the Nostr path. - return BookingQuote( - booking=booking, - payment_request=payment.bolt11, - payment_hash=payment.payment_hash, - ) + return await services.request_booking(data) + except services.BookingError as exc: + raise HTTPException(502, str(exc)) from exc + except ValueError as exc: + raise _to_http(exc) from exc @chatelet_api_router.get("/api/v1/bookings/{booking_id}") @@ -196,15 +112,3 @@ async def api_create_block( room, block.start_date, block.end_date, block.id ) return block - - -# --- helpers --------------------------------------------------------------- - - -async def _to_sats(amount: float, currency: str) -> int: - """Fiat/currency -> sats. Canonical conversion happens HERE, once, at - quote/hold time; the result is stored as amount_sat and never recomputed - downstream.""" - if currency.lower() in ("sat", "sats"): - return int(amount) - return await fiat_amount_as_satoshis(amount, currency) From 5b176e5186f0ce39730c66c5b67bdd59c485c026 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 01:30:13 +0200 Subject: [PATCH 2/3] 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 From 1b65890f42889531e88d008d1cea223ff001ee38 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 15:37:06 +0200 Subject: [PATCH 3/3] docs: reframe kind:22000/22001 as retained proposal, not redundant Per the client-agnostic doctrine (workspace CLAUDE.md): the availability RPC is the training wheel, the public kind:22000/22001 is the destination. Keep the custom kinds as a proposal so a generic Nostr client can one day query availability without our RPC. Published via the nostrclient relay path (#2). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- docs/event-flow.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/event-flow.md b/docs/event-flow.md index 5989195..b8fc7cc 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -41,11 +41,15 @@ scoped, so the *operator* can stream booking settlements 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). +> **Custom kinds vs the RPC — training wheels, not redundancy:** the +> `chatelet_availability` RPC is what ships first, but the ephemeral +> `kind:22000/22001` availability query/response (ADR-0001) is **retained as a +> proposal**, not dropped. The RPC is the training wheel; the public event kind +> is the client-agnostic destination — a generic Nostr client must one day be +> able to query availability *without* speaking our private RPC (workspace +> doctrine: extensions aim to be Nostr-client-agnostic). It will be published +> over the nostrclient relay path alongside the NIP-99/52 discovery events +> (issue #2). ## Actors