From 4d6dba548708e0408ac3982ec4ee21f4825edfc1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 01:39:39 +0200 Subject: [PATCH 1/4] fix: strip checkin_instructions from public room dicts (privacy leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AUTH_NONE RPC room endpoints (chatelet_room_list/_get) stripped wallet but NOT checkin_instructions — which was added in #5 after this code, so the operator's private access details (address, gate code) were leaking to any guest. Centralize a public_room_dict(room) helper (strips wallet + checkin_instructions) and route both doors through it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- models.py | 12 ++++++++++++ transport_rpcs.py | 9 ++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) 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/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) -- 2.53.0 From 0631edf8a27b4f7cf037f914932c57515392438c Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 01:39:39 +0200 Subject: [PATCH 2/4] feat: public guest room discovery endpoints (slice 1 for webapp #141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /public/rooms + /public/rooms/{id} — no auth, active rooms only, operator-private fields stripped. The webapp guest UI needs these because the existing GET /rooms is admin-scoped; availability + booking POST are already public. Reuses the public_room_dict strip. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- views_api.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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) -------------------------------------------- -- 2.53.0 From 408b0e1d088d82671264f9f611b87f046ec7f826 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 01:39:39 +0200 Subject: [PATCH 3/4] test: public endpoints + private-field strip Asserts public_room_dict drops wallet + checkin_instructions, public list shows active-only + stripped, and public get 404s on inactive. 32 pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- tests/test_public_endpoints.py | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/test_public_endpoints.py 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 -- 2.53.0 From 6fa7774c9f9533422921ad45bc4233094db218a6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 01:39:39 +0200 Subject: [PATCH 4/4] chore: bump version 0.2.0 -> 0.3.0 (public guest endpoints) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", -- 2.53.0