diff --git a/README.md b/README.md index 1ae8a1e..ae7536d 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,9 @@ Nostr-native room rentals for LNbits — an "Airbnb for the castle". List rooms, take booking requests, arbitrate availability, and settle stays over Lightning, with Nostr as the interop layer. -> **Status:** functional prototype. Data model, migrations, availability -> arbiter (atomic hold), FX + invoice + settlement, the REST + kind-21000 RPC -> doors, and the nostrclient relay layer (publish + availability queries) are -> in place and tested. The guest UI and encrypted-event delivery pre-bunker -> are the remaining gaps. -> -> **Soft dependency:** publishing/subscribing to relays uses the **nostrclient** -> extension (imported in-process). Install + activate it to reach relays; if -> absent, the booking flow still works over HTTP/RPC and relay publish is -> skipped with a logged warning. Encrypted events (reservation `30078`, check-in -> DMs) additionally need a bunker/server-signing signer (lnbits #18); on a -> LocalSigner they soft-fail until then. +> **Status:** design sketch + scaffold. Data model, migrations, availability +> arbiter, REST surface, and Nostr event model are in place; relay plumbing, +> FX/invoice wiring, and the guest UI are stubbed with `TODO(...)` markers. ## Why not just reuse an existing NIP? diff --git a/__init__.py b/__init__.py index a8fa672..910eb39 100644 --- a/__init__.py +++ b/__init__.py @@ -36,15 +36,6 @@ def chatelet_start(): create_permanent_unique_task("chatelet_hold_expiry", expire_holds_loop) ) - # Inbound Nostr: answer kind:22000 availability queries over relays via - # nostrclient (client-agnostic availability path). Self-backs-off if - # nostrclient isn't installed. - from .nostr.service import subscribe_inbound - - scheduled_tasks.append( - create_permanent_unique_task("chatelet_availability_sub", subscribe_inbound) - ) - # Expose the booking flow over the core LNbits nostr transport (kind-21000 # RPC) so an HTTP-allergic client can drive Chatelet over relays. Also wire # the booking-owner resolver so the operator can stream booking diff --git a/docs/event-flow.md b/docs/event-flow.md index 26b5976..6b51df2 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -70,33 +70,6 @@ wallet, so the guest confirms by polling `chatelet_booking_get` until | `1059` | 17/59 | both | Private booking DMs (request → quote → confirm → check-in) | | `22000/22001` | aiolabs | guest/operator | Live availability query + response (ephemeral) | -## Relay transport — nostrclient (in-process) - -Publishing app/discovery events and subscribing to inbound queries go through -the **`nostrclient` extension's** relay manager, imported in-process -(`nostr_client.relay_manager.publish_message(...)` / `.add_subscription(...)`), -the same pattern `spirekeeper`/`nostrmarket` use. This is separate from the -core kind-21000 RPC transport (Door 2): the core pool is RPC-only and can't -publish arbitrary kinds, so app events ride nostrclient. Adds a **soft runtime -dependency** on nostrclient — if it isn't installed, publish/subscribe skip -with a logged warning and the booking flow (HTTP/RPC) is unaffected. - -`nostr/service.py` implements it: - -- **Publish** (`_sign_and_publish`): sign as the operator (`resolve_signer`), - optionally NIP-44-encrypt, then `publish_message`. -- **Subscribe** (`subscribe_inbound`, a permanent task): register a - `kind:22000` subscription, poll `NostrRouter.received_subscription_events`, - answer each with a `kind:22001` reply. - -**Public vs encrypted — what works pre-bunker:** a `LocalSigner` can -`sign_event` but its `nip44_encrypt` raises (bunker-forward by design, lnbits -#18). So **public** events (listing `30402`, calendar `31923`, availability -`22001` — availability is public info, so `22000/22001` are plaintext) publish -today; **encrypted** events (reservation `30078`, and the `#5` check-in DM) -sign-encrypt via the operator signer and soft-fail with a clear log until the -operator has a bunker/server-signing signer. Nothing crashes either way. - ## Happy path ```mermaid diff --git a/nostr/events.py b/nostr/events.py index b0c9d8e..2ad6cdb 100644 --- a/nostr/events.py +++ b/nostr/events.py @@ -67,13 +67,13 @@ def build_reservation_event(booking: Booking, guest_pubkey: str) -> dict: } return { "kind": KIND_RESERVATION, - # plaintext JSON; service.py NIP-44-encrypts this to the guest before - # signing (publish_reservation passes encrypt_to=guest_pubkey). + # placeholder — service.py replaces with NIP-44(payload) for guest "content": json.dumps(payload), "tags": [ ["d", booking.id], ["p", guest_pubkey], ], + "_plaintext": payload, # consumed + stripped by service.py before signing } @@ -91,6 +91,7 @@ def build_block_calendar_event( ["title", f"{room.title} — unavailable"], ["start", start_date], ["end", end_date], + ["a", f"{KIND_LISTING}:{{operator_pubkey}}:{room.id}"], # link to listing ], } @@ -98,13 +99,8 @@ def build_block_calendar_event( def build_availability_response( result: AvailabilityResult, requester_pubkey: str ) -> dict: - """Aiolabs kind:22001 (ephemeral) reply to an availability query. - - Plaintext by design: room availability for a date range is public - information (the same facts published in the NIP-52 calendar), so this - needs no encryption — which also means it works today without a bunker/ - server-signing signer (only sign_event is required, not nip44_encrypt). - p-tagged to the requester so their client can match the reply.""" + """Aiolabs kind:22001 (ephemeral) reply to an availability query. Content + MUST be NIP-44 encrypted to the requester by service.py.""" payload = { "room_id": result.room_id, "check_in": result.check_in, @@ -119,4 +115,5 @@ def build_availability_response( "kind": KIND_AVAILABILITY_RESPONSE, "content": json.dumps(payload), "tags": [["p", requester_pubkey]], + "_plaintext": payload, } diff --git a/nostr/service.py b/nostr/service.py index d2c6d7e..7345ef2 100644 --- a/nostr/service.py +++ b/nostr/service.py @@ -1,141 +1,86 @@ -"""Nostr I/O for Chatelet: sign + publish outbound events, and subscribe to -inbound availability queries — over the `nostrclient` extension's relay -manager, in-process (the spirekeeper pattern, aiolabs/spirekeeper -nostr_publish.py + tasks.py). +"""Nostr I/O for Chatelet: sign+publish outbound events, subscribe to +inbound guest requests. SKETCH — the relay plumbing is stubbed; the shape +and the signer/encryption boundaries are what matter here. -Signing + NIP-44 go through `lnbits.core.signers.resolve_signer`: the -operator account's signer produces the signature and (for encrypted events) -the NIP-44 ciphertext. No nsec is read here — works with a server-signing / -NIP-46 bunker signer. A LocalSigner can `sign_event` but its `nip44_encrypt` -raises (bunker-forward by design), so **public** events (listing 30402, -calendar 31923, availability 22001) publish today, while **encrypted** events -(reservation 30078) soft-fail with a clear log until a bunker lands (lnbits -#18). Everything degrades gracefully: no operator onboarded, nostrclient not -installed, or signer can't encrypt → publish is skipped, never crashes the -booking flow (which stands on its own over HTTP/RPC). +Signing + NIP-44 go through lnbits.core.signers.resolve_signer, following +the spirekeeper hybrid pattern (aiolabs/spirekeeper nostr_publish.py): +resolve the operator account's signer, then sign_event / nip44_encrypt. +No nsec is ever read here — works with LocalSigner now, NIP-46 bunker +after lnbits#18. If the signer module isn't present (pre-#17 lnbits), the +whole Nostr layer soft-disables and Chatelet still works over REST. """ -import asyncio -import json -import time - from loguru import logger -from .. import crud, services -from ..models import Booking, Room +from .. import crud +from ..models import AvailabilityResult, Booking, Room from . import events -from .kinds import KIND_AVAILABILITY_QUERY try: from lnbits.core.crud import get_account from lnbits.core.signers import resolve_signer - from lnbits.core.signers.base import SignerUnavailableError _SIGNER_AVAILABLE = True -except ImportError: # pre-signer lnbits — Nostr layer soft-disables +except ImportError: # pre-#17 lnbits — Nostr layer soft-disables _SIGNER_AVAILABLE = False -_AVAILABILITY_SUB_ID = "chatelet_availability_query" -_POLL_INTERVAL_S = 1.0 -_BACKOFF_S = 30 - - -class _NostrclientUnavailable(Exception): - """nostrclient extension not importable; caller backs off and retries.""" - - -def _nostrclient(): - try: - from nostrclient.router import ( # type: ignore[import-not-found] - NostrRouter, - nostr_client, - ) - except ImportError as exc: - raise _NostrclientUnavailable() from exc - return NostrRouter, nostr_client - - -# --- signer ----------------------------------------------------------------- - async def _operator_signer(): - """(account, signer) for the configured operator, or (None, None) if the - Nostr layer is unavailable / no operator onboarded.""" + """Resolve the operator account + its signer, or (None, None) if the + Nostr layer is unavailable / not onboarded.""" if not _SIGNER_AVAILABLE: return None, None settings = await crud.get_or_create_settings() if not settings.operator_id: - logger.debug("chatelet: no operator_id configured; skipping publish") + logger.warning("chatelet: no operator_id configured; skipping publish") return None, None account = await get_account(settings.operator_id) if not account or not account.pubkey: - logger.warning("chatelet: operator account has no Nostr pubkey; skipping") return None, None return account, resolve_signer(account) -# --- outbound: sign + publish ----------------------------------------------- - - async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None: - """Encrypt (if `encrypt_to`), sign as the operator, and publish via - nostrclient. Returns the event id, or None on any soft-fail.""" + """Sign `unsigned` as the operator, NIP-44-encrypting `content` to + `encrypt_to` first if requested, and publish to configured relays. + Returns the event id, or None if the Nostr layer is off. + + TODO(relay): wire actual relay publish. Options, cheapest first: + 1. reuse the `nostrclient` extension's relay manager if installed; + 2. the core nostr_transport relay pool (lnbits#4); + 3. a direct websockets fan-out like lnurlp/tasks.py send_to_relay. + """ _account, signer = await _operator_signer() if not signer: return None - if encrypt_to: - try: - unsigned["content"] = await signer.nip44_encrypt( - unsigned.get("content", ""), encrypt_to - ) - except SignerUnavailableError as exc: - logger.warning( - f"chatelet: cannot NIP-44 encrypt kind={unsigned.get('kind')} " - f"(needs bunker/server-signing signer, lnbits#18): {exc}" - ) - return None + plaintext = unsigned.pop("_plaintext", None) + if encrypt_to and plaintext is not None: + unsigned["content"] = await signer.nip44_encrypt(encrypt_to, unsigned["content"]) - unsigned["created_at"] = int(time.time()) # part of the event-id hash - try: - signed = await signer.sign_event(unsigned) - except SignerUnavailableError as exc: - logger.warning(f"chatelet: signer cannot sign kind={unsigned.get('kind')}: {exc}") - return None - if not signed: - return None - - try: - _, nostr_client = _nostrclient() - except _NostrclientUnavailable: - logger.warning( - "chatelet: nostrclient extension not installed; publish skipped. " - "Install + activate nostrclient to reach relays." - ) - return None - nostr_client.relay_manager.publish_message(json.dumps(["EVENT", signed])) - logger.debug( - f"chatelet: published kind={signed['kind']} id={signed.get('id', '')[:12]}" - ) + signed = await signer.sign_event(unsigned) # adds pubkey/created_at/id/sig + # TODO(relay): await relay_pool.publish(signed, settings.relays) + logger.debug(f"chatelet: (would) publish kind={signed['kind']} id={signed.get('id')}") return signed.get("id") +# --- outbound: operator-published events ----------------------------------- + + async def publish_listing(room: Room) -> str | None: - """Publish/refresh a room's NIP-99 kind:30402 listing (public).""" + """Publish/refresh a room's NIP-99 listing. Returns event id to persist + on room.listing_event_id.""" return await _sign_and_publish(events.build_listing_event(room)) async def publish_reservation(booking: Booking) -> str | None: - """Publish the guest's kind:30078 reservation object, NIP-44 encrypted to - them. Soft-fails on a LocalSigner (no server-side encrypt) until bunker.""" + """Publish the guest's encrypted kind:30078 reservation object. Called + on every status transition so the guest's durable copy stays current.""" unsigned = events.build_reservation_event(booking, booking.guest_pubkey) return await _sign_and_publish(unsigned, encrypt_to=booking.guest_pubkey) -async def publish_block_calendar( - room: Room, start: str, end: str, block_id: str -) -> str | None: - """Publish a kind:31923 unavailable-range event (public, no PII).""" +async def publish_block_calendar(room: Room, start: str, end: str, block_id: str) -> str | None: settings = await crud.get_or_create_settings() if not settings.publish_availability: return None @@ -144,78 +89,26 @@ async def publish_block_calendar( ) -async def respond_availability(result, requester_pubkey: str) -> str | None: - """Publish a kind:22001 availability response (plaintext, public info).""" - return await _sign_and_publish( - events.build_availability_response(result, requester_pubkey) - ) +async def respond_availability(result: AvailabilityResult, requester_pubkey: str) -> str | None: + unsigned = events.build_availability_response(result, requester_pubkey) + return await _sign_and_publish(unsigned, encrypt_to=requester_pubkey) -# --- inbound: availability queries (kind:22000) ----------------------------- +# --- inbound: guest-originated events --------------------------------------- async def subscribe_inbound() -> None: - """Permanent task: subscribe to kind:22000 availability queries over - nostrclient and answer each with a kind:22001 response. This is the - client-agnostic availability path (any Nostr client can ask), parallel - to the chatelet_availability RPC. Booking itself stays on the RPC/HTTP - doors for now (see issue #2 / event-flow.md).""" - registered = False - while True: - try: - registered = await _availability_tick(registered) - await asyncio.sleep(_POLL_INTERVAL_S) - except _NostrclientUnavailable: - logger.warning( - "chatelet: nostrclient not installed; availability subscription " - f"sleeping {_BACKOFF_S}s before retry." - ) - registered = False - await asyncio.sleep(_BACKOFF_S) - except Exception as exc: # a listener must never die - logger.error(f"chatelet: availability consumer error (continuing): {exc}") - await asyncio.sleep(_POLL_INTERVAL_S) + """Long-running subscription for guest events on configured relays: + * kind:22000 availability query -> compute + respond_availability() + * NIP-59 giftwrap booking request -> hold booking, DM back a quote + * NIP-59 giftwrap booking confirm -> (payment drives confirm; see tasks) -async def _availability_tick(registered: bool) -> bool: - NostrRouter, nostr_client = _nostrclient() - if not registered: - nostr_client.relay_manager.add_subscription( - _AVAILABILITY_SUB_ID, - [{"kinds": [KIND_AVAILABILITY_QUERY]}], # type: ignore[list-item] - ) - logger.info( - f"chatelet: subscribed to kind:{KIND_AVAILABILITY_QUERY} " - "availability queries" - ) + SKETCH — no relay connection yet. Started as a permanent task from + __init__.py:chatelet_start() once relay plumbing lands. - inbound = NostrRouter.received_subscription_events.get(_AVAILABILITY_SUB_ID) - while inbound: - event_message = inbound.pop(0) - try: - await _handle_availability_query(event_message) - except Exception as exc: - logger.warning(f"chatelet: availability query handler failed (skip): {exc}") - return True - - -async def _handle_availability_query(event_message) -> None: - """Parse a kind:22000 query, compute availability, publish a 22001 reply - p-tagged to the asker. The event author is the requester (unspoofable — - it's the signed pubkey).""" - event = json.loads(event_message.event) - requester = event.get("pubkey") - body = json.loads(event.get("content") or "{}") - room_id, check_in, check_out = ( - body.get("room_id"), - body.get("check_in"), - body.get("check_out"), - ) - if not (requester and room_id and check_in and check_out): - return # malformed query — ignore - - try: - result = await services.get_availability(room_id, check_in, check_out) - except ValueError: - return # unknown room / bad dates — no reply - await respond_availability(result, requester) + TODO(relay): open subscription, NIP-44-decrypt via the operator signer, + dispatch to handlers that reuse the same crud paths as the REST API so + HTTP and Nostr entry points converge on one booking flow. + """ + logger.info("chatelet: inbound Nostr subscription not yet wired (sketch)") diff --git a/tests/test_events.py b/tests/test_events.py deleted file mode 100644 index 972e4b0..0000000 --- a/tests/test_events.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Nostr event builders — tag/kind/content shape (no signing, no relays).""" - -import json - -from ..models import AvailabilityResult, Booking, BookingStatus -from ..nostr import events -from ..nostr.kinds import ( - KIND_AVAILABILITY_RESPONSE, - KIND_CALENDAR_EVENT, - KIND_LISTING, - KIND_RESERVATION, -) -from .conftest import make_room - - -def _tag(ev, name): - """First tag value for `name`, or None.""" - return next((t[1] for t in ev["tags"] if t[0] == name), None) - - -def _tags(ev, name): - return [t[1] for t in ev["tags"] if t[0] == name] - - -def test_listing_event_shape(): - room = make_room( - currency="EUR", price=80.0, - ) - room.amenities = ["wifi", "breakfast"] - room.location = "Château" - room.geohash = "u0j2" - room.images = ["https://img/1.jpg"] - - ev = events.build_listing_event(room) - - assert ev["kind"] == KIND_LISTING # 30402 - assert _tag(ev, "d") == room.id # addressable: re-publish replaces - assert _tag(ev, "title") == "Tower Room" - price = next(t for t in ev["tags"] if t[0] == "price") - assert price == ["price", "80.0", "EUR", "night"] - assert _tag(ev, "status") == "active" - assert _tag(ev, "location") == "Château" - assert _tag(ev, "g") == "u0j2" - assert _tags(ev, "image") == ["https://img/1.jpg"] - assert set(_tags(ev, "t")) == {"wifi", "breakfast"} - - -def test_reservation_event_is_plaintext_json_no_scaffolding(): - booking = Booking( - id="bk1", room_id="room1", guest_pubkey="npub_guest", - check_in="2026-08-01", check_out="2026-08-04", nights=3, num_guests=2, - currency="sat", price_fiat=300.0, amount_sat=300, deposit_sat=300, - status=BookingStatus.confirmed, - ) - ev = events.build_reservation_event(booking, booking.guest_pubkey) - - assert ev["kind"] == KIND_RESERVATION # 30078 - assert _tag(ev, "d") == "bk1" - assert _tag(ev, "p") == "npub_guest" - assert "_plaintext" not in ev # scaffolding removed; service.py encrypts content - payload = json.loads(ev["content"]) - assert payload["amount_sat"] == 300 # canonical, carried verbatim - assert payload["status"] == "confirmed" - - -def test_block_calendar_event_shape(): - room = make_room() - ev = events.build_block_calendar_event(room, "2026-09-01", "2026-09-05", "blk1") - - assert ev["kind"] == KIND_CALENDAR_EVENT # 31923 - assert _tag(ev, "d") == "room1:blk1" - assert _tag(ev, "start") == "2026-09-01" - assert _tag(ev, "end") == "2026-09-05" - # the broken {operator_pubkey} 'a' tag was removed - assert not any(t[0] == "a" for t in ev["tags"]) - - -def test_availability_response_is_plaintext(): - result = AvailabilityResult( - room_id="room1", check_in="2026-08-01", check_out="2026-08-04", - available=True, nights=3, quote_sat=300, quote_fiat=3.0, currency="sat", - ) - ev = events.build_availability_response(result, "npub_requester") - - assert ev["kind"] == KIND_AVAILABILITY_RESPONSE # 22001 - assert _tag(ev, "p") == "npub_requester" - assert "_plaintext" not in ev # public info: no encryption - payload = json.loads(ev["content"]) - assert payload["available"] is True - assert payload["quote_sat"] == 300