diff --git a/__init__.py b/__init__.py index 61fe782..6d151fb 100644 --- a/__init__.py +++ b/__init__.py @@ -67,6 +67,7 @@ def chatelet_start(): handle_availability, handle_block_create, handle_booking_get, + handle_booking_list_mine, handle_booking_request, handle_operator_get, handle_operator_update, @@ -95,6 +96,7 @@ def chatelet_start(): 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) + register_rpc("chatelet_booking_list_mine", handle_booking_list_mine, AUTH_NONE) # tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid), # so override the default link_extra_key ("link") to match. diff --git a/crud.py b/crud.py index 46e7ebf..d863e11 100644 --- a/crud.py +++ b/crud.py @@ -139,6 +139,18 @@ async def get_booking_by_payment_hash(payment_hash: str) -> Booking | None: ) +async def get_bookings_for_guest(guest_pubkey: str, limit: int = 200) -> list[Booking]: + """A guest's own stays, newest check-in first (idx_bookings_guest_pubkey).""" + return await db.fetchall( + """ + SELECT * FROM chatelet.bookings WHERE guest_pubkey = :pk + ORDER BY check_in DESC LIMIT :limit + """, + {"pk": guest_pubkey, "limit": limit}, + Booking, + ) + + async def get_bookings_for_room(room_id: str) -> list[Booking]: return await db.fetchall( "SELECT * FROM chatelet.bookings WHERE room_id = :rid", diff --git a/docs/event-flow.md b/docs/event-flow.md index 124472f..c28b30c 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -31,6 +31,7 @@ flow runs over relays with no HTTP: | `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`) | +| `chatelet_booking_list_mine` | none | the caller's own bookings (`sender_pubkey`; HTTP twin `GET /api/v1/bookings/mine` uses the account's 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 diff --git a/models.py b/models.py index d243cca..7539436 100644 --- a/models.py +++ b/models.py @@ -259,6 +259,16 @@ def public_booking_dict(booking: "Booking") -> dict: return d +def guest_booking_dict(booking: "Booking") -> dict: + """A Booking for the guest who owns it (authenticated by pubkey on either + door): everything public_booking_dict shows plus their own contact and + guest count; the Lightning/Nostr plumbing stays internal.""" + d = json.loads(booking.json()) + for k in ("payment_hash", "request_event_id", "reservation_event_id"): + d.pop(k, None) + return d + + class AvailabilityQuery(BaseModel): room_id: str check_in: str # YYYY-MM-DD inclusive diff --git a/services.py b/services.py index 0502a7f..3f23dfa 100644 --- a/services.py +++ b/services.py @@ -36,6 +36,7 @@ from .models import ( Room, RoomStatus, UnavailableRanges, + guest_booking_dict, public_room_dict, ) @@ -151,6 +152,12 @@ async def public_room_views(rooms: list[Room]) -> list[dict]: return out +async def list_guest_bookings(guest_pubkey: str) -> list[dict]: + """The caller's own bookings (identity established by the door: LNbits + account pubkey over HTTP, signed sender_pubkey over RPC).""" + return [guest_booking_dict(b) for b in await crud.get_bookings_for_guest(guest_pubkey)] + + # A guest calendar asks for a year by default; cap the window so a bad client # can't make us scan and ship an unbounded span. DEFAULT_CALENDAR_DAYS = 365 diff --git a/tests/test_my_bookings.py b/tests/test_my_bookings.py new file mode 100644 index 0000000..f76e37e --- /dev/null +++ b/tests/test_my_bookings.py @@ -0,0 +1,89 @@ +"""A guest's own bookings, on both doors. HTTP identity is the LNbits account +pubkey; RPC identity is the signed sender_pubkey. Neither leaks another +guest's rows, and the Lightning/Nostr plumbing stays internal.""" + +import asyncio +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from .. import crud, transport_rpcs, views_api +from ..models import Booking, BookingStatus, guest_booking_dict + +PK = "ab" * 32 + + +def _booking(i: int, **over) -> Booking: + base = dict( + id=f"bk{i}", + room_id="a", + guest_pubkey=PK, + guest_contact="me@example.com", + check_in=f"2026-10-{10 + i:02d}", + check_out=f"2026-10-{12 + i:02d}", + nights=2, + num_guests=1, + currency="EUR", + price_fiat=200.0, + amount_sat=300000, + deposit_sat=300000, + status=BookingStatus.confirmed, + payment_hash=f"ph{i}", + request_event_id="req", + reservation_event_id="res", + ) + base.update(over) + return Booking(**base) # type: ignore[arg-type] + + +def _patch_store(monkeypatch, rows): + seen = {} + + async def for_guest(pubkey, limit=200): + seen["pubkey"] = pubkey + return [b for b in rows if b.guest_pubkey == pubkey] + + monkeypatch.setattr(crud, "get_bookings_for_guest", for_guest) + return seen + + +def test_guest_dict_keeps_own_contact_but_hides_plumbing(): + d = guest_booking_dict(_booking(1)) + assert d["guest_contact"] == "me@example.com" + assert d["guest_pubkey"] == PK + for hidden in ("payment_hash", "request_event_id", "reservation_event_id"): + assert hidden not in d + + +def test_http_lists_only_the_callers_rows(monkeypatch): + rows = [_booking(1), _booking(2, guest_pubkey="cd" * 32)] + seen = _patch_store(monkeypatch, rows) + user = SimpleNamespace(id="u1", pubkey=PK) + out = asyncio.run(views_api.api_my_bookings(user=user)) + assert seen["pubkey"] == PK + assert [b["id"] for b in out] == ["bk1"] + assert "payment_hash" not in out[0] + + +def test_http_rejects_account_without_pubkey(monkeypatch): + _patch_store(monkeypatch, []) + with pytest.raises(HTTPException) as e: + asyncio.run(views_api.api_my_bookings(user=SimpleNamespace(id="u1", pubkey=None))) + assert e.value.status_code == 409 + + +def test_rpc_scopes_by_sender_and_requires_it(monkeypatch): + rows = [_booking(1)] + _patch_store(monkeypatch, rows) + req = transport_rpcs.NostrRpcRequest( + rpc_name="chatelet_booking_list_mine", request_id="r", body={}, sender_pubkey=PK + ) + out = asyncio.run(transport_rpcs.handle_booking_list_mine(None, req)) + assert [b["id"] for b in out] == ["bk1"] + + anon = transport_rpcs.NostrRpcRequest( + rpc_name="chatelet_booking_list_mine", request_id="r", body={} + ) + with pytest.raises(PermissionError): + asyncio.run(transport_rpcs.handle_booking_list_mine(None, anon)) diff --git a/transport_rpcs.py b/transport_rpcs.py index 44785da..a6c755d 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -177,6 +177,13 @@ async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict: return _to_dict(quote) +async def handle_booking_list_mine(auth: None, request: NostrRpcRequest) -> list[dict]: + """The caller's own bookings, scoped by the signed sender_pubkey.""" + if not request.sender_pubkey: + raise PermissionError("chatelet: caller identity required") + return await services.list_guest_bookings(request.sender_pubkey) + + async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict: booking = await crud.get_booking(_require_id(request)) if not booking: diff --git a/views_api.py b/views_api.py index 2f7ee3e..4682527 100644 --- a/views_api.py +++ b/views_api.py @@ -8,8 +8,8 @@ the guest-facing surface (availability, booking) is what also rides the RPC. """ from fastapi import APIRouter, Depends, HTTPException, Query -from lnbits.core.models import WalletTypeInfo -from lnbits.decorators import require_admin_key, require_invoice_key +from lnbits.core.models import User, WalletTypeInfo +from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key from . import crud, services from .models import ( @@ -286,6 +286,16 @@ async def api_request_booking(data: BookingRequestData) -> BookingQuote: raise _to_http(exc) from exc +@chatelet_api_router.get("/api/v1/bookings/mine") +async def api_my_bookings(user: User = Depends(check_user_exists)) -> list[dict]: + """The signed-in guest's own stays. Identity is the LNbits account's Nostr + pubkey — the same value the booking request carried as guest_pubkey. + Declared before /bookings/{booking_id} so "mine" is not read as an id.""" + if not user.pubkey: + raise HTTPException(409, "This account has no Nostr pubkey") + return await services.list_guest_bookings(user.pubkey) + + @chatelet_api_router.get("/api/v1/bookings/{booking_id}") async def api_get_booking( booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key)