feat: public guest discovery endpoints + privacy fix (v0.3.0) #17

Merged
padreug merged 4 commits from feat/public-guest-endpoints into main 2026-07-19 23:43:39 +00:00
5 changed files with 101 additions and 4 deletions

View file

@ -1,6 +1,6 @@
{ {
"id": "chatelet", "id": "chatelet",
"version": "0.2.0", "version": "0.3.0",
"name": "Chatelet", "name": "Chatelet",
"repo": "https://git.atitlan.io/aiolabs/chatelet", "repo": "https://git.atitlan.io/aiolabs/chatelet",
"short_description": "Nostr-native room rentals (Airbnb-style) for LNbits", "short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",

View file

@ -17,6 +17,7 @@ Design notes carried into the field definitions:
arbiter of "is this range open" Nostr events are requests, not locks. arbiter of "is this range open" Nostr events are requests, not locks.
""" """
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum 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): 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

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

View file

@ -196,6 +196,9 @@ def _to_dict(obj) -> dict:
def _public_room(room) -> dict: def _public_room(room) -> dict:
d = _to_dict(room) # Shared with the HTTP door; strips wallet id AND checkin_instructions
d.pop("wallet", None) # wallet id is operator-internal, not for guests # (the latter was leaking to guests before — added after this file's
return d # original public dict).
from .models import public_room_dict
return public_room_dict(room)

View file

@ -24,6 +24,7 @@ from .models import (
CreateRoomData, CreateRoomData,
Room, Room,
RoomStatus, RoomStatus,
public_room_dict,
) )
from .nostr import service as nostr from .nostr import service as nostr
@ -189,6 +190,27 @@ async def api_update_settings(
return await crud.update_settings(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) -------------------------------------------- # --- availability (public read) --------------------------------------------