feat(api): a guest's own bookings on both doors

GET /api/v1/bookings/mine (LNbits account auth; identity = the account's
Nostr pubkey, the same value the booking request carried) and RPC twin
chatelet_booking_list_mine (scoped by the signed sender_pubkey). Rows come
back newest check-in first via the m003 guest index, as guest_booking_dict:
the guest's own contact and counts, minus the Lightning/Nostr plumbing.
Declared ahead of /bookings/{booking_id} so 'mine' is not read as an id.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-16 12:17:39 +02:00
commit 8b816d83b0
8 changed files with 140 additions and 2 deletions

View file

@ -67,6 +67,7 @@ def chatelet_start():
handle_availability, handle_availability,
handle_block_create, handle_block_create,
handle_booking_get, handle_booking_get,
handle_booking_list_mine,
handle_booking_request, handle_booking_request,
handle_operator_get, handle_operator_get,
handle_operator_update, handle_operator_update,
@ -95,6 +96,7 @@ def chatelet_start():
register_rpc("chatelet_availability", handle_availability, AUTH_NONE) register_rpc("chatelet_availability", handle_availability, AUTH_NONE)
register_rpc("chatelet_booking_request", handle_booking_request, 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_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), # tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid),
# so override the default link_extra_key ("link") to match. # so override the default link_extra_key ("link") to match.

12
crud.py
View file

@ -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]: async def get_bookings_for_room(room_id: str) -> list[Booking]:
return await db.fetchall( return await db.fetchall(
"SELECT * FROM chatelet.bookings WHERE room_id = :rid", "SELECT * FROM chatelet.bookings WHERE room_id = :rid",

View file

@ -31,6 +31,7 @@ flow runs over relays with no HTTP:
| `chatelet_availability` | none | is a range free + a quote | | `chatelet_availability` | none | is a range free + a quote |
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) | | `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_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 Guest identity is the `sender_pubkey` the dispatcher lifts off the signed
kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is

View file

@ -259,6 +259,16 @@ def public_booking_dict(booking: "Booking") -> dict:
return d 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): class AvailabilityQuery(BaseModel):
room_id: str room_id: str
check_in: str # YYYY-MM-DD inclusive check_in: str # YYYY-MM-DD inclusive

View file

@ -36,6 +36,7 @@ from .models import (
Room, Room,
RoomStatus, RoomStatus,
UnavailableRanges, UnavailableRanges,
guest_booking_dict,
public_room_dict, public_room_dict,
) )
@ -151,6 +152,12 @@ async def public_room_views(rooms: list[Room]) -> list[dict]:
return out 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 # 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. # can't make us scan and ship an unbounded span.
DEFAULT_CALENDAR_DAYS = 365 DEFAULT_CALENDAR_DAYS = 365

89
tests/test_my_bookings.py Normal file
View file

@ -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))

View file

@ -177,6 +177,13 @@ async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict:
return _to_dict(quote) 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: async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict:
booking = await crud.get_booking(_require_id(request)) booking = await crud.get_booking(_require_id(request))
if not booking: if not booking:

View file

@ -8,8 +8,8 @@ the guest-facing surface (availability, booking) is what also rides the RPC.
""" """
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key
from . import crud, services from . import crud, services
from .models import ( from .models import (
@ -286,6 +286,16 @@ async def api_request_booking(data: BookingRequestData) -> BookingQuote:
raise _to_http(exc) from exc 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}") @chatelet_api_router.get("/api/v1/bookings/{booking_id}")
async def api_get_booking( async def api_get_booking(
booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key) booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key)