From 94542ad0f95a4d1e8b9686b2fe07dcdbf4e302e9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 15 Sep 2026 23:43:21 +0200 Subject: [PATCH] feat(api): keyless GET /api/v1/public/bookings/{id} for guests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest cannot subscribe to the operator's wallet and the existing booking read needs a wallet invoice key, so a client had no way to wait for awaiting_payment -> confirmed over HTTP short of polling the invoice on LNbits core. Add the HTTP twin of the RPC door's chatelet_booking_get: the 10-char booking id from the quote is the capability, and the response is public_booking_dict — lifecycle, dates and money only, with the guest's pubkey/contact and the Lightning/Nostr plumbing stripped. Closes #18 Co-Authored-By: Claude Fable 5.1 --- docs/event-flow.md | 5 ++- models.py | 19 +++++++++ tests/test_public_endpoints.py | 71 +++++++++++++++++++++++++++++++++- views_api.py | 13 +++++++ 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/docs/event-flow.md b/docs/event-flow.md index 93b33bf..8fce44a 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -39,7 +39,10 @@ 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). +`confirmed` (a guest push would need a NIP-17 DM — issue #5). The HTTP door +has the same read as keyless `GET /api/v1/public/bookings/{id}` (issue #18): +the booking id from the quote is the capability, and the response is the +same identity-stripped shape (`public_booking_dict`) on both doors. > **Custom kinds vs the RPC — training wheels, not redundancy:** the > `chatelet_availability` RPC is what ships first, but the ephemeral diff --git a/models.py b/models.py index 6fd20a8..be6f22d 100644 --- a/models.py +++ b/models.py @@ -208,6 +208,25 @@ def public_room_dict(room: Room) -> dict: class AvailabilityQuery(BaseModel): room_id: str +def public_booking_dict(booking: "Booking") -> dict: + """A Booking as public JSON for the guest who holds its id — lifecycle + + money + dates only. Strips the guest's own identity/contact (so the id + alone can't be turned into PII) and the internal Lightning/Nostr + plumbing. The 10-char id from the quote is the capability here, the same + trust level as the RPC door's chatelet_booking_get; chatelet_booking_get + additionally scopes by sender_pubkey, which HTTP can't.""" + d = json.loads(booking.json()) + for k in ( + "guest_pubkey", + "guest_contact", + "payment_hash", + "request_event_id", + "reservation_event_id", + ): + d.pop(k, None) + return d + + check_in: str # YYYY-MM-DD inclusive check_out: str # YYYY-MM-DD exclusive diff --git a/tests/test_public_endpoints.py b/tests/test_public_endpoints.py index 363ae7c..8150b9a 100644 --- a/tests/test_public_endpoints.py +++ b/tests/test_public_endpoints.py @@ -7,7 +7,13 @@ import pytest from fastapi import HTTPException from .. import crud, views_api -from ..models import RoomStatus, public_room_dict +from ..models import ( + Booking, + BookingStatus, + RoomStatus, + public_booking_dict, + public_room_dict, +) from .conftest import make_room @@ -58,3 +64,66 @@ def test_public_room_returns_stripped_when_active(monkeypatch): assert out["id"] == "a" assert "checkin_instructions" not in out assert "wallet" not in out + + +def _booking(**overrides) -> Booking: + base = { + "id": "bk_1234567", + "room_id": "a", + "guest_pubkey": "ab" * 32, + "guest_contact": "guest@example.com", + "check_in": "2026-10-05", + "check_out": "2026-10-07", + "nights": 2, + "num_guests": 1, + "currency": "EUR", + "price_fiat": 200.0, + "amount_sat": 300000, + "deposit_sat": 300000, + "status": BookingStatus.awaiting_payment, + "payment_hash": "ph_1", + "request_event_id": "req_ev", + "reservation_event_id": "res_ev", + } + base.update(overrides) + return Booking(**base) + + +def test_public_booking_dict_strips_identity_and_plumbing(): + d = public_booking_dict(_booking()) + for private in ( + "guest_pubkey", + "guest_contact", + "payment_hash", + "request_event_id", + "reservation_event_id", + ): + assert private not in d + # What a guest client needs to render "waiting" / "booked" / "expired". + assert d["id"] == "bk_1234567" + assert d["status"] == "awaiting_payment" + assert d["check_in"] == "2026-10-05" + assert d["nights"] == 2 + assert d["deposit_sat"] == 300000 + assert "expires_at" in d + + +def test_public_booking_404_when_missing(monkeypatch): + async def gb(_): + return None + + monkeypatch.setattr(crud, "get_booking", gb) + with pytest.raises(HTTPException) as e: + asyncio.run(views_api.api_public_booking("nope")) + assert e.value.status_code == 404 + + +def test_public_booking_returns_stripped(monkeypatch): + async def gb(_): + return _booking(status=BookingStatus.confirmed) + + monkeypatch.setattr(crud, "get_booking", gb) + out = asyncio.run(views_api.api_public_booking("bk_1234567")) + assert out["status"] == "confirmed" + assert "guest_contact" not in out + assert "payment_hash" not in out diff --git a/views_api.py b/views_api.py index 7b28cde..cc45519 100644 --- a/views_api.py +++ b/views_api.py @@ -24,6 +24,7 @@ from .models import ( CreateRoomData, Room, RoomStatus, + public_booking_dict, public_room_dict, ) from .nostr import service as nostr @@ -243,3 +244,15 @@ async def api_get_booking( if not booking: raise HTTPException(404, "Booking not found") return booking + + +@chatelet_api_router.get("/api/v1/public/bookings/{booking_id}") +async def api_public_booking(booking_id: str) -> dict: + """Guest read-back of their own booking (keyless). The guest can't + subscribe to the operator's wallet, so this is how a client waits for + `awaiting_payment` -> `confirmed` (or sees `expired` / `declined`) + without a key — the HTTP twin of the RPC door's chatelet_booking_get.""" + booking = await crud.get_booking(booking_id) + if not booking: + raise HTTPException(404, "Booking not found") + return public_booking_dict(booking)