From 0dad30b648a45f88329201c2a8d19c15adef42cb Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 16 Sep 2026 12:25:31 +0200 Subject: [PATCH 1/2] feat: card rail via LNbits fiat providers (Stripe), per operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guest may pay a fiat-priced room by card when its owner has opted in and LNbits core has a fiat provider for that user — resolved through settings.get_fiat_providers_for_user(owner), the seam lnbits#67's per-user Stripe Connect credentials will plug into; chatelet stores no credentials. Both rails now go through create_payment_request: Lightning unchanged (sats, deposit_sat), card charges the same deposit share of the fiat price in the room's currency with extra.checkout parameterising the hosted Stripe page — success/cancel return to {frontend}/chatelet/{room}?checkout=… &booking=, customer_email, line item, metadata. frontend_url is allow-listed against the instance's trusted origins (ported from events) and resolved before the hold so a refused rail never leaves a dead hold. Core settles the Stripe webhook onto the same invoice queue, so tasks.on_invoice_paid confirms card bookings unchanged. BookingRequestData gains payment_method / fiat_provider / frontend_url; BookingQuote gains fiat_payment_request / fiat_provider / is_fiat and a nullable payment_request. RPC chatelet_booking_request passes the fields through. min_lnbits_version → 1.4.1 (events' floor for these APIs). Co-Authored-By: Claude Fable 5.1 --- config.json | 2 +- docs/event-flow.md | 2 +- frontend.py | 47 +++++++++++ models.py | 30 ++++++- services.py | 94 +++++++++++++++++---- tests/test_atomic_hold.py | 16 ++-- tests/test_booking_flow.py | 36 ++++++-- tests/test_fiat_checkout.py | 161 ++++++++++++++++++++++++++++++++++++ tests/test_frontend_root.py | 50 +++++++++++ transport_rpcs.py | 5 ++ views_api.py | 10 ++- 11 files changed, 417 insertions(+), 36 deletions(-) create mode 100644 frontend.py create mode 100644 tests/test_fiat_checkout.py create mode 100644 tests/test_frontend_root.py diff --git a/config.json b/config.json index 7ce0586..fe3f148 100644 --- a/config.json +++ b/config.json @@ -6,7 +6,7 @@ "short_description": "Nostr-native room rentals (Airbnb-style) for LNbits", "description": "", "tile": "/chatelet/static/image/aio.png", - "min_lnbits_version": "1.4.0", + "min_lnbits_version": "1.4.1", "contributors": [ { "name": "padreug", diff --git a/docs/event-flow.md b/docs/event-flow.md index c28b30c..5dc1cbf 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -29,7 +29,7 @@ flow runs over relays with no HTTP: | `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped, owner's `house_rules` + `payment_methods` attached) | | `chatelet_room_unavailable` | none | merged occupied/blocked spans over a window — the guest calendar feed (HTTP twin: `GET /api/v1/public/rooms/{id}/unavailable`) | | `chatelet_availability` | none | is a range free + a quote | -| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) | +| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`); `payment_method` `lightning` (default) or `fiat` + optional `fiat_provider` / `frontend_url` — card returns `fiat_payment_request` (hosted checkout URL) instead of a bolt11 | | `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) | | `chatelet_booking_list_mine` | none | the caller's own bookings (`sender_pubkey`; HTTP twin `GET /api/v1/bookings/mine` uses the account's pubkey) | diff --git a/frontend.py b/frontend.py new file mode 100644 index 0000000..c1630f5 --- /dev/null +++ b/frontend.py @@ -0,0 +1,47 @@ +"""Where to send a guest back to after a hosted (Stripe) checkout. + +Ported from the events extension. The calling app names itself via +`frontend_url`; we only honour origins the LNbits instance already trusts +(the CORS allow-list, its own base URL, the configured custom frontend), and +fail loud on anything else — a wrong root would strand the guest in the +wrong app after paying. Transport-agnostic: the HTTP door passes the request +base URL as fallback, the RPC door has none and falls back to the instance. +""" + +from urllib.parse import urlsplit + +from lnbits.settings import settings + + +def origin(url: str | None) -> str | None: + if not url: + return None + parts = urlsplit(url.strip()) + if not parts.scheme or not parts.netloc: + return None + return f"{parts.scheme.lower()}://{parts.netloc.lower()}" + + +def allowed_frontend_origins() -> set[str]: + origins: set[str] = set() + for candidate in [ + *getattr(settings, "lnbits_cors_allowed_origins", []), + settings.lnbits_baseurl, + getattr(settings, "lnbits_custom_frontend_url", None), + ]: + o = origin(candidate) + if o: + origins.add(o) + return origins + + +def resolve_frontend_root( + frontend_url: str | None, fallback_base_url: str | None +) -> str: + """Root under which `/chatelet/{room_id}` resolves for the guest.""" + if not frontend_url: + return (fallback_base_url or settings.lnbits_baseurl or "").rstrip("/") + o = origin(frontend_url) + if not o or o not in allowed_frontend_origins(): + raise ValueError("frontend_url origin is not allowed.") + return frontend_url.rstrip("/") diff --git a/models.py b/models.py index 7539436..0d49422 100644 --- a/models.py +++ b/models.py @@ -20,8 +20,9 @@ Design notes carried into the field definitions: import json from datetime import datetime, timezone from enum import Enum +from urllib.parse import urlsplit -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator def _now() -> datetime: @@ -167,6 +168,27 @@ class BookingRequestData(BaseModel): num_guests: int = 1 guest_contact: str | None = None # optional email/phone/nostr note message: str | None = None # free-form note to the host + # Rail the guest wants to pay with. "fiat" needs the room owner to accept + # card AND LNbits core to have a provider for them (services checks). + payment_method: str = "lightning" + fiat_provider: str | None = None # e.g. "stripe"; defaults to the owner's first + # Where the hosted checkout should send the guest back (the calling app); + # origin must be one the instance trusts — see frontend.resolve_frontend_root. + frontend_url: str | None = Field(default=None, max_length=512) + + @validator("frontend_url") + def validate_frontend_url(cls, v): # noqa: N805 + if v is None: + return None + v = v.strip() + if not v: + return None + parts = urlsplit(v) + if parts.scheme not in ("http", "https") or not parts.netloc: + raise ValueError("frontend_url must be an absolute http(s) URL") + if parts.query or parts.fragment or ".." in parts.path: + raise ValueError("frontend_url must not contain a query, fragment or '..'") + return v.rstrip("/") class Booking(BaseModel): @@ -316,5 +338,9 @@ class BookingQuote(BaseModel): computes what they owe.""" booking: Booking - payment_request: str + payment_request: str | None # bolt11 — None on the card rail payment_hash: str + # Card rail: the provider's hosted checkout URL to send the guest to. + fiat_payment_request: str | None = None + fiat_provider: str | None = None + is_fiat: bool = False diff --git a/services.py b/services.py index d137a1c..2fc9662 100644 --- a/services.py +++ b/services.py @@ -17,13 +17,15 @@ from collections import defaultdict from datetime import date, datetime, timedelta, timezone from lnbits.core.crud.wallets import get_wallet -from lnbits.core.services import create_invoice +from lnbits.core.models.payments import CreateInvoice +from lnbits.core.services import create_payment_request from lnbits.exceptions import InvoiceError from lnbits.helpers import urlsafe_short_hash from lnbits.settings import settings as lnbits_settings from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from . import crud +from .frontend import resolve_frontend_root from .models import ( HOUSE_RULE_FIELDS, AvailabilityResult, @@ -225,10 +227,35 @@ async def get_availability( ) -async def request_booking(data: BookingRequestData) -> BookingQuote: +async def _resolve_rail( + room: Room, data: BookingRequestData, base_url: str | None +) -> tuple[str | None, str]: + """(fiat provider or None for Lightning, frontend root for the return + URLs). Providers come from LNbits core for the room *owner* — the seam + lnbits#67's per-user Stripe creds will plug into.""" + method = (data.payment_method or LIGHTNING).lower() + if method not in (LIGHTNING, FIAT): + raise ValueError("Unknown payment method") + owner = await room_owner_id(room) + ops = await crud.get_or_create_operator_settings(owner) + if method not in payment_methods_for_room(room, owner, ops): + raise ValueError("Payment method not enabled for this room") + if method == LIGHTNING: + return None, "" + providers = fiat_providers_for_user(owner) + provider = data.fiat_provider or (providers[0] if providers else None) + if not provider or provider not in providers: + raise ValueError("No fiat payment provider configured") + return provider, resolve_frontend_root(data.frontend_url, base_url) + + +async def request_booking( + data: BookingRequestData, *, base_url: str | None = None +) -> BookingQuote: """Check-then-hold, then invoice. The `is_available` read + the `held` write are the lock; TODO(#4) makes that pair atomic against a concurrent - request. Returns the held booking + the bolt11 that will confirm it.""" + request. Returns the held booking + what confirms it: a bolt11, or on the + card rail the provider's hosted-checkout URL.""" room = await crud.get_room(data.room_id) if not room or room.status != RoomStatus.active: raise NotFound("Room not available") @@ -239,6 +266,10 @@ async def request_booking(data: BookingRequestData) -> BookingQuote: if data.num_guests > room.max_guests: raise ValueError(f"Max {room.max_guests} guests") + # Rail + provider resolution happens before the hold so a refused rail + # never leaves a dead hold behind. + provider, frontend_root = await _resolve_rail(room, data, base_url) + # Compute the canonical amount up front (FX call) so the lock below wraps # only the DB check + insert, never the slow network work. settings = await crud.get_or_create_settings() @@ -280,30 +311,59 @@ async def request_booking(data: BookingRequestData) -> BookingQuote: raise Unavailable("Those dates are no longer available") await crud.create_booking(booking) - # Sats-denominated (deposit_sat locked at quote time) so FX drift before - # payment can't change what's owed. tag+booking_id let - # tasks.on_invoice_paid match the settlement back to this booking. - try: - payment = await create_invoice( - wallet_id=room.wallet, - amount=booking.deposit_sat, - memo=( - f"Chatelet · {room.title} · " - f"{booking.check_in}→{booking.check_out} ({nights}n)" + # One invoice call for both rails (core forks on fiat_provider). Lightning + # is sats-denominated (deposit_sat locked at quote time so FX drift can't + # change what's owed); card charges the same deposit share of the fiat + # price in the room's currency — core refuses sat units for fiat, which + # payment_methods_for_room already rules out. tag+booking_id let + # tasks.on_invoice_paid match the settlement back to this booking on + # either rail, since core settles Stripe onto the same invoice queue. + stay = f"{booking.check_in}→{booking.check_out} ({nights}n)" + memo = f"Chatelet · {room.title} · {stay}" + extra: dict = {"tag": "chatelet", "booking_id": booking.id} + invoice = CreateInvoice( + out=False, amount=booking.deposit_sat, unit="sat", memo=memo, extra=extra + ) + if provider: + back = f"{frontend_root}/chatelet/{room.id}" + extra["checkout"] = { + "success_url": f"{back}?checkout=success&booking={booking.id}", + "cancel_url": f"{back}?checkout=cancelled&booking={booking.id}", + "customer_email": ( + data.guest_contact + if data.guest_contact and "@" in data.guest_contact + else None ), - extra={"tag": "chatelet", "booking_id": booking.id}, + "line_item_name": f"{room.title} · {stay}", + "metadata": {"booking_id": booking.id, "room_id": room.id}, + } + invoice = CreateInvoice( + out=False, + amount=round(price_fiat * settings.deposit_percent / 100, 2), + unit=room.price_currency, + fiat_provider=provider, + memo=memo, + extra=extra, ) - except InvoiceError as exc: + try: + payment = await create_payment_request( + wallet_id=room.wallet, invoice_data=invoice + ) + except (InvoiceError, ValueError) as exc: booking.status = BookingStatus.declined # dead hold -> free the dates await crud.update_booking(booking) - raise BookingError(f"Could not create invoice: {exc.message}") from exc + raise BookingError(f"Could not create invoice: {exc}") from exc booking.payment_hash = payment.payment_hash booking.status = BookingStatus.awaiting_payment await crud.update_booking(booking) + payment_extra = getattr(payment, "extra", None) or {} return BookingQuote( booking=booking, - payment_request=payment.bolt11, + payment_request=getattr(payment, "bolt11", None) or None, payment_hash=payment.payment_hash, + fiat_payment_request=payment_extra.get("fiat_payment_request"), + fiat_provider=getattr(payment, "fiat_provider", None) or provider, + is_fiat=provider is not None, ) diff --git a/tests/test_atomic_hold.py b/tests/test_atomic_hold.py index cf1ec0f..023f2aa 100644 --- a/tests/test_atomic_hold.py +++ b/tests/test_atomic_hold.py @@ -12,7 +12,7 @@ from types import SimpleNamespace from .. import crud, services from ..models import BookingQuote -from .conftest import make_request, make_room +from .conftest import make_request, make_room, patch_owner def _setup(monkeypatch, room): @@ -39,10 +39,13 @@ def _setup(monkeypatch, room): async def fake_update_booking(booking): return booking - async def fake_create_invoice(**kwargs): - invoices.append(kwargs) + async def fake_create_payment_request(*, wallet_id, invoice_data): + invoices.append(invoice_data) return SimpleNamespace( - payment_hash="ph_" + kwargs["extra"]["booking_id"], bolt11="lnbc_fake" + payment_hash="ph_" + invoice_data.extra["booking_id"], + bolt11="lnbc_fake", + fiat_provider=None, + extra=invoice_data.extra, ) monkeypatch.setattr(crud, "get_room", fake_get_room) @@ -50,7 +53,10 @@ def _setup(monkeypatch, room): monkeypatch.setattr(crud, "is_available", fake_is_available) monkeypatch.setattr(crud, "create_booking", fake_create_booking) monkeypatch.setattr(crud, "update_booking", fake_update_booking) - monkeypatch.setattr(services, "create_invoice", fake_create_invoice) + monkeypatch.setattr( + services, "create_payment_request", fake_create_payment_request + ) + patch_owner(monkeypatch) return held, invoices diff --git a/tests/test_booking_flow.py b/tests/test_booking_flow.py index a8e0e59..032eaef 100644 --- a/tests/test_booking_flow.py +++ b/tests/test_booking_flow.py @@ -9,12 +9,16 @@ from lnbits.exceptions import InvoiceError from .. import crud, services from ..models import BookingQuote, BookingStatus -from .conftest import make_request, make_room +from .conftest import make_request, make_room, patch_owner -def _setup(monkeypatch, room, *, invoice_raises=False): +def _setup( + monkeypatch, room, *, invoice_raises=False, accept_fiat=False, providers=() +): created: list = [] # bookings passed to create_booking updated: list = [] # bookings passed to update_booking (captures final state) + invoices: list = [] # CreateInvoice objects handed to core + patch_owner(monkeypatch, accept_fiat=accept_fiat, providers=providers) async def fake_get_room(_): return room @@ -36,18 +40,36 @@ def _setup(monkeypatch, room, *, invoice_raises=False): updated.append(booking) return booking - async def fake_create_invoice(**kwargs): + async def fake_create_payment_request(*, wallet_id, invoice_data): + invoices.append(invoice_data) if invoice_raises: raise InvoiceError("no funding source") - return SimpleNamespace(payment_hash="ph_1", bolt11="lnbc_fake") + if invoice_data.fiat_provider: + return SimpleNamespace( + payment_hash="ph_1", + bolt11=None, + fiat_provider=invoice_data.fiat_provider, + extra={ + **invoice_data.extra, + "fiat_payment_request": "https://checkout.stripe.test/s/1", + }, + ) + return SimpleNamespace( + payment_hash="ph_1", + bolt11="lnbc_fake", + fiat_provider=None, + extra=invoice_data.extra, + ) monkeypatch.setattr(crud, "get_room", fake_get_room) monkeypatch.setattr(crud, "get_or_create_settings", fake_settings) monkeypatch.setattr(crud, "is_available", fake_is_available) monkeypatch.setattr(crud, "create_booking", fake_create_booking) monkeypatch.setattr(crud, "update_booking", fake_update_booking) - monkeypatch.setattr(services, "create_invoice", fake_create_invoice) - return created, updated + monkeypatch.setattr( + services, "create_payment_request", fake_create_payment_request + ) + return created, updated, invoices def test_happy_path_holds_then_awaits_payment(monkeypatch): @@ -70,7 +92,7 @@ def test_min_nights_enforced(monkeypatch): def test_invoice_failure_releases_hold(monkeypatch): - created, updated = _setup( + created, updated, _ = _setup( monkeypatch, make_room(), invoice_raises=True ) with pytest.raises(services.BookingError): diff --git a/tests/test_fiat_checkout.py b/tests/test_fiat_checkout.py new file mode 100644 index 0000000..ea84359 --- /dev/null +++ b/tests/test_fiat_checkout.py @@ -0,0 +1,161 @@ +"""Card rail on request_booking: the operator opted in, core has a provider +for that owner, the room is fiat-priced — and the hosted checkout returns the +guest to the room with the booking id.""" + +import asyncio +from types import SimpleNamespace +from typing import Any + +import pytest +from lnbits.settings import settings + +from .. import crud, services +from ..models import BookingRequestData, BookingStatus +from .conftest import make_room, patch_owner + + +def _wire(monkeypatch, room, *, accept_fiat=True, providers=("stripe",), fail=False): + patch_owner(monkeypatch, accept_fiat=accept_fiat, providers=providers) + monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/") + monkeypatch.setattr( + settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False + ) + invoices: list = [] + updated: list = [] + + async def gr(_): + return room + + async def gs(): + return SimpleNamespace(deposit_percent=50, default_hold_minutes=30) + + async def avail(*_): + return True + + async def create(b): + return b + + async def update(b): + updated.append(b) + return b + + async def fake_cpr(*, wallet_id, invoice_data): + invoices.append(invoice_data) + if fail: + raise ValueError("Cannot create payment request: provider down") + return SimpleNamespace( + payment_hash="ph_f", + bolt11=None, + fiat_provider=invoice_data.fiat_provider, + extra={ + **invoice_data.extra, + "fiat_payment_request": "https://checkout.stripe.test/s/1", + }, + ) + + async def rate(amount, currency): + return 150_000 # sats for the whole stay; irrelevant to the fiat charge + + monkeypatch.setattr(crud, "get_room", gr) + monkeypatch.setattr(crud, "get_or_create_settings", gs) + monkeypatch.setattr(crud, "is_available", avail) + monkeypatch.setattr(crud, "create_booking", create) + monkeypatch.setattr(crud, "update_booking", update) + monkeypatch.setattr(services, "create_payment_request", fake_cpr) + monkeypatch.setattr(services, "fiat_amount_as_satoshis", rate) + return invoices, updated + + +def _req(**over: Any) -> BookingRequestData: + base: dict[str, Any] = { + "room_id": "room1", + "guest_pubkey": "ab" * 32, + "check_in": "2026-11-01", + "check_out": "2026-11-03", + "guest_contact": "guest@example.com", + "payment_method": "fiat", + "frontend_url": "https://app.example/chatelet", + } + base.update(over) + return BookingRequestData(**base) + + +def test_card_happy_path_returns_checkout_url(monkeypatch): + room = make_room("room1", price=100.0, currency="EUR") + invoices, _ = _wire(monkeypatch, room) + quote = asyncio.run(services.request_booking(_req(), base_url="https://lnbits.example/")) + + assert quote.is_fiat and quote.fiat_provider == "stripe" + assert quote.payment_request is None + assert quote.fiat_payment_request == "https://checkout.stripe.test/s/1" + assert quote.booking.status == BookingStatus.awaiting_payment + + inv = invoices[0] + assert inv.fiat_provider == "stripe" and inv.unit == "EUR" + assert inv.amount == 100.0 # 2 nights x 100 EUR at deposit_percent 50 + assert inv.extra["tag"] == "chatelet" + assert inv.extra["booking_id"] == quote.booking.id + co = inv.extra["checkout"] + assert co["success_url"] == ( + "https://app.example/chatelet/chatelet/room1" + f"?checkout=success&booking={quote.booking.id}" + ) + assert co["cancel_url"].endswith(f"?checkout=cancelled&booking={quote.booking.id}") + assert co["customer_email"] == "guest@example.com" + assert co["metadata"] == {"booking_id": quote.booking.id, "room_id": "room1"} + + +def test_lightning_still_uses_sats_and_bolt11(monkeypatch): + room = make_room("room1", price=100.0, currency="EUR") + invoices, _ = _wire(monkeypatch, room) + + async def fake_cpr(*, wallet_id, invoice_data): + invoices.append(invoice_data) + return SimpleNamespace( + payment_hash="ph_l", bolt11="lnbc1", fiat_provider=None, extra={} + ) + + monkeypatch.setattr(services, "create_payment_request", fake_cpr) + quote = asyncio.run(services.request_booking(_req(payment_method="lightning"))) + assert not quote.is_fiat and quote.payment_request == "lnbc1" + assert invoices[0].unit == "sat" and invoices[0].fiat_provider is None + assert invoices[0].amount == 75_000 # deposit_percent 50 of 150k sats + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"accept_fiat": False}, "not enabled"), + ({"providers": ()}, "not enabled"), # no provider → rail not offered at all + ], +) +def test_card_refused_before_any_hold(monkeypatch, kwargs, message): + room = make_room("room1", price=100.0, currency="EUR") + invoices, updated = _wire(monkeypatch, room, **kwargs) + with pytest.raises(ValueError, match=message): + asyncio.run(services.request_booking(_req())) + assert invoices == [] and updated == [] # refused up front, nothing held + + +def test_sat_priced_room_cannot_take_card(monkeypatch): + room = make_room("room1", price=1000.0, currency="sat") + invoices, _ = _wire(monkeypatch, room) + with pytest.raises(ValueError, match="not enabled"): + asyncio.run(services.request_booking(_req())) + assert invoices == [] + + +def test_unlisted_frontend_is_rejected_before_hold(monkeypatch): + room = make_room("room1", price=100.0, currency="EUR") + invoices, updated = _wire(monkeypatch, room) + with pytest.raises(ValueError, match="frontend_url"): + asyncio.run(services.request_booking(_req(frontend_url="https://evil.example/x"))) + assert invoices == [] and updated == [] + + +def test_provider_failure_releases_hold(monkeypatch): + room = make_room("room1", price=100.0, currency="EUR") + _, updated = _wire(monkeypatch, room, fail=True) + with pytest.raises(services.BookingError): + asyncio.run(services.request_booking(_req())) + assert updated[-1].status == BookingStatus.declined diff --git a/tests/test_frontend_root.py b/tests/test_frontend_root.py new file mode 100644 index 0000000..15ac279 --- /dev/null +++ b/tests/test_frontend_root.py @@ -0,0 +1,50 @@ +"""Hosted-checkout return root: only origins the instance trusts.""" + +import pytest +from lnbits.settings import settings + +from ..frontend import allowed_frontend_origins, resolve_frontend_root + + +@pytest.fixture +def lnbits_settings(monkeypatch): + monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/") + monkeypatch.setattr( + settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False + ) + monkeypatch.setattr( + settings, + "lnbits_custom_frontend_url", + "https://Front.Example/login", + raising=False, + ) + + +def test_allowlist_collects_every_configured_origin(lnbits_settings): + assert allowed_frontend_origins() == { + "https://lnbits.example", + "https://app.example", + "https://front.example", + } + + +def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings): + root = resolve_frontend_root(None, "https://lnbits.example/") + assert root == "https://lnbits.example" + + +def test_absent_frontend_url_and_no_request_uses_instance_base(lnbits_settings): + # The RPC door has no request host. + assert resolve_frontend_root(None, None) == "https://lnbits.example" + + +def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings): + out = resolve_frontend_root( + "https://app.example/chatelet/", "https://lnbits.example/" + ) + assert out == "https://app.example/chatelet" + + +def test_unlisted_origin_is_rejected_loudly(lnbits_settings): + with pytest.raises(ValueError, match="frontend_url"): + resolve_frontend_root("https://evil.example/x", "https://lnbits.example/") diff --git a/transport_rpcs.py b/transport_rpcs.py index a6c755d..b522520 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -172,7 +172,12 @@ async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict: num_guests=body.get("num_guests", 1), guest_contact=body.get("guest_contact"), message=body.get("message"), + payment_method=body.get("payment_method", "lightning"), + fiat_provider=body.get("fiat_provider"), + frontend_url=body.get("frontend_url"), ) + # No request host on this door: the checkout returns to the instance root + # unless the client names its own (allow-listed) frontend_url. quote = await services.request_booking(data) return _to_dict(quote) diff --git a/views_api.py b/views_api.py index 4682527..521267c 100644 --- a/views_api.py +++ b/views_api.py @@ -7,7 +7,7 @@ endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI; the guest-facing surface (availability, booking) is what also rides the RPC. """ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from lnbits.core.models import User, WalletTypeInfo from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key @@ -277,9 +277,13 @@ async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: @chatelet_api_router.post("/api/v1/bookings", status_code=201) -async def api_request_booking(data: BookingRequestData) -> BookingQuote: +async def api_request_booking( + data: BookingRequestData, request: Request +) -> BookingQuote: try: - return await services.request_booking(data) + return await services.request_booking( + data, base_url=str(request.base_url) + ) except services.BookingError as exc: raise HTTPException(502, str(exc)) from exc except ValueError as exc: From f7e0d51fd686d4a5f7cbe86708c5ed94623dec70 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 21 Sep 2026 11:06:16 +0200 Subject: [PATCH 2/2] chore(release): v0.5.0 #20 restores check_in/check_out on AvailabilityQuery (v0.4.0 shipped the availability endpoint broken); #21 adds the keyless /public/rooms/{id}/unavailable feed for the guest calendar. Co-Authored-By: Claude Fable 5.1 --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 7ce0586..7a9cef8 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "chatelet", - "version": "0.4.0", + "version": "0.5.0", "name": "Chatelet", "repo": "https://git.atitlan.io/aiolabs/chatelet", "short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",