diff --git a/config.json b/config.json index 908806c..4bd7233 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "chatelet", - "version": "0.2.0", + "version": "0.3.0", "name": "Chatelet", "repo": "https://git.atitlan.io/aiolabs/chatelet", "short_description": "Nostr-native room rentals (Airbnb-style) for LNbits", diff --git a/models.py b/models.py index 8a239e3..6fd20a8 100644 --- a/models.py +++ b/models.py @@ -17,6 +17,7 @@ Design notes carried into the field definitions: arbiter of "is this range open" — Nostr events are requests, not locks. """ +import json from datetime import datetime, timezone from enum import Enum @@ -194,6 +195,17 @@ class Block(BaseModel): # --------------------------------------------------------------------------- +def public_room_dict(room: Room) -> dict: + """A Room as public JSON for guests — strips operator-private fields: the + wallet id, and the check-in instructions (address/gate code, delivered only + in the encrypted post-payment DM). Shared by the HTTP and Nostr-RPC guest + doors so neither can leak them.""" + d = json.loads(room.json()) + d.pop("wallet", None) + d.pop("checkin_instructions", None) + return d + + class AvailabilityQuery(BaseModel): room_id: str check_in: str # YYYY-MM-DD inclusive diff --git a/tests/test_public_endpoints.py b/tests/test_public_endpoints.py new file mode 100644 index 0000000..363ae7c --- /dev/null +++ b/tests/test_public_endpoints.py @@ -0,0 +1,60 @@ +"""Public guest discovery endpoints + the operator-private field strip +(privacy: check-in instructions must never reach a guest).""" + +import asyncio + +import pytest +from fastapi import HTTPException + +from .. import crud, views_api +from ..models import RoomStatus, public_room_dict +from .conftest import make_room + + +def test_public_room_dict_strips_private_fields(): + room = make_room(wallet="w1") + room.checkin_instructions = "gate code 4213, door on the left" + d = public_room_dict(room) + assert "wallet" not in d # operator-internal + assert "checkin_instructions" not in d # private, DM-only + assert d["title"] == "Tower Room" # public fields survive + assert d["price_amount"] == 100 + + +def test_public_rooms_lists_active_only_and_stripped(monkeypatch): + active = make_room("a", status=RoomStatus.active) + active.checkin_instructions = "secret" + inactive = make_room("b", status=RoomStatus.inactive) + + async def gr(): + return [active, inactive] + + monkeypatch.setattr(crud, "get_rooms", gr) + out = asyncio.run(views_api.api_public_rooms()) + assert [r["id"] for r in out] == ["a"] # inactive hidden from guests + assert "checkin_instructions" not in out[0] + assert "wallet" not in out[0] + + +def test_public_room_404_when_inactive(monkeypatch): + async def gr(_): + return make_room(status=RoomStatus.inactive) + + monkeypatch.setattr(crud, "get_room", gr) + with pytest.raises(HTTPException) as e: + asyncio.run(views_api.api_public_room("x")) + assert e.value.status_code == 404 + + +def test_public_room_returns_stripped_when_active(monkeypatch): + room = make_room("a", status=RoomStatus.active) + room.checkin_instructions = "gate" + + async def gr(_): + return room + + monkeypatch.setattr(crud, "get_room", gr) + out = asyncio.run(views_api.api_public_room("a")) + assert out["id"] == "a" + assert "checkin_instructions" not in out + assert "wallet" not in out diff --git a/transport_rpcs.py b/transport_rpcs.py index f8f634b..d3e5cc3 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -196,6 +196,9 @@ def _to_dict(obj) -> dict: def _public_room(room) -> dict: - d = _to_dict(room) - d.pop("wallet", None) # wallet id is operator-internal, not for guests - return d + # Shared with the HTTP door; strips wallet id AND checkin_instructions + # (the latter was leaking to guests before — added after this file's + # original public dict). + from .models import public_room_dict + + return public_room_dict(room) diff --git a/views_api.py b/views_api.py index 1f24a87..7b28cde 100644 --- a/views_api.py +++ b/views_api.py @@ -24,6 +24,7 @@ from .models import ( CreateRoomData, Room, RoomStatus, + public_room_dict, ) from .nostr import service as nostr @@ -189,6 +190,27 @@ async def api_update_settings( return await crud.update_settings(settings) +# --- public guest discovery (no auth) -------------------------------------- + + +@chatelet_api_router.get("/api/v1/public/rooms") +async def api_public_rooms() -> list[dict]: + """Active rooms for guest browsing — operator-private fields stripped.""" + return [ + public_room_dict(r) + for r in await crud.get_rooms() + if r.status == RoomStatus.active + ] + + +@chatelet_api_router.get("/api/v1/public/rooms/{room_id}") +async def api_public_room(room_id: str) -> dict: + room = await crud.get_room(room_id) + if not room or room.status != RoomStatus.active: + raise HTTPException(404, "Room not available") + return public_room_dict(room) + + # --- availability (public read) --------------------------------------------