feat(api): keyless public booking read-back for guests #19
4 changed files with 106 additions and 2 deletions
|
|
@ -39,7 +39,10 @@ scoped, so the *operator* can stream booking settlements
|
||||||
(`subscribe_payments({tag:"chatelet", link_id:<booking_id>})`, wired via
|
(`subscribe_payments({tag:"chatelet", link_id:<booking_id>})`, wired via
|
||||||
`register_link_owner_resolver`). A **guest** can't subscribe to the operator's
|
`register_link_owner_resolver`). A **guest** can't subscribe to the operator's
|
||||||
wallet, so the guest confirms by polling `chatelet_booking_get` until
|
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
|
> **Custom kinds vs the RPC — training wheels, not redundancy:** the
|
||||||
> `chatelet_availability` RPC is what ships first, but the ephemeral
|
> `chatelet_availability` RPC is what ships first, but the ephemeral
|
||||||
|
|
|
||||||
19
models.py
19
models.py
|
|
@ -208,6 +208,25 @@ def public_room_dict(room: Room) -> dict:
|
||||||
|
|
||||||
class AvailabilityQuery(BaseModel):
|
class AvailabilityQuery(BaseModel):
|
||||||
room_id: str
|
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_in: str # YYYY-MM-DD inclusive
|
||||||
check_out: str # YYYY-MM-DD exclusive
|
check_out: str # YYYY-MM-DD exclusive
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from .. import crud, views_api
|
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
|
from .conftest import make_room
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,3 +64,66 @@ def test_public_room_returns_stripped_when_active(monkeypatch):
|
||||||
assert out["id"] == "a"
|
assert out["id"] == "a"
|
||||||
assert "checkin_instructions" not in out
|
assert "checkin_instructions" not in out
|
||||||
assert "wallet" 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
|
||||||
|
|
|
||||||
13
views_api.py
13
views_api.py
|
|
@ -24,6 +24,7 @@ from .models import (
|
||||||
CreateRoomData,
|
CreateRoomData,
|
||||||
Room,
|
Room,
|
||||||
RoomStatus,
|
RoomStatus,
|
||||||
|
public_booking_dict,
|
||||||
public_room_dict,
|
public_room_dict,
|
||||||
)
|
)
|
||||||
from .nostr import service as nostr
|
from .nostr import service as nostr
|
||||||
|
|
@ -243,3 +244,15 @@ async def api_get_booking(
|
||||||
if not booking:
|
if not booking:
|
||||||
raise HTTPException(404, "Booking not found")
|
raise HTTPException(404, "Booking not found")
|
||||||
return booking
|
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue