From cf9f8699da4cb4dcb0e96f55d9648b9c3e0db1c7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 18:09:35 +0200 Subject: [PATCH 1/3] feat: relay publish + availability subscription via nostrclient (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the Nostr layer through the nostrclient extension's relay manager in-process (spirekeeper pattern), replacing the sketch stubs: - _sign_and_publish: sign as operator (resolve_signer), optional NIP-44 encrypt, publish via nostr_client.relay_manager.publish_message. Soft- fails (logs, returns None) if no operator onboarded, nostrclient absent, or signer can't encrypt — never crashes the booking flow. - publish_listing (30402) + publish_block_calendar (31923): public, work today (sign_event only). - publish_reservation (30078): NIP-44 encrypted to guest; soft-fails on a LocalSigner until a bunker/server-signing signer lands (lnbits#18). - subscribe_inbound: permanent task answering kind:22000 availability queries with kind:22001 (plaintext — availability is public info), the client-agnostic availability path parallel to the RPC. events.py: drop the _plaintext scaffolding (service.py encrypts content in place) and a broken {operator_pubkey} calendar tag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- __init__.py | 9 ++ nostr/events.py | 15 ++-- nostr/service.py | 213 +++++++++++++++++++++++++++++++++++------------ 3 files changed, 178 insertions(+), 59 deletions(-) diff --git a/__init__.py b/__init__.py index 910eb39..a8fa672 100644 --- a/__init__.py +++ b/__init__.py @@ -36,6 +36,15 @@ 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/nostr/events.py b/nostr/events.py index 2ad6cdb..b0c9d8e 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, - # placeholder — service.py replaces with NIP-44(payload) for guest + # plaintext JSON; service.py NIP-44-encrypts this to the guest before + # signing (publish_reservation passes encrypt_to=guest_pubkey). "content": json.dumps(payload), "tags": [ ["d", booking.id], ["p", guest_pubkey], ], - "_plaintext": payload, # consumed + stripped by service.py before signing } @@ -91,7 +91,6 @@ 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 ], } @@ -99,8 +98,13 @@ def build_block_calendar_event( def build_availability_response( result: AvailabilityResult, requester_pubkey: str ) -> dict: - """Aiolabs kind:22001 (ephemeral) reply to an availability query. Content - MUST be NIP-44 encrypted to the requester by service.py.""" + """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.""" payload = { "room_id": result.room_id, "check_in": result.check_in, @@ -115,5 +119,4 @@ 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 7345ef2..d2c6d7e 100644 --- a/nostr/service.py +++ b/nostr/service.py @@ -1,86 +1,141 @@ -"""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. +"""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). -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. +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). """ +import asyncio +import json +import time + from loguru import logger -from .. import crud -from ..models import AvailabilityResult, Booking, Room +from .. import crud, services +from ..models import 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-#17 lnbits — Nostr layer soft-disables +except ImportError: # pre-signer 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(): - """Resolve the operator account + its signer, or (None, None) if the - Nostr layer is unavailable / not onboarded.""" + """(account, signer) for the configured operator, or (None, None) if the + Nostr layer is unavailable / no operator onboarded.""" if not _SIGNER_AVAILABLE: return None, None settings = await crud.get_or_create_settings() if not settings.operator_id: - logger.warning("chatelet: no operator_id configured; skipping publish") + logger.debug("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) -async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None: - """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. +# --- outbound: sign + publish ----------------------------------------------- - 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. - """ + +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.""" _account, signer = await _operator_signer() if not signer: 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"]) + 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 - 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')}") + 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]}" + ) return signed.get("id") -# --- outbound: operator-published events ----------------------------------- - - async def publish_listing(room: Room) -> str | None: - """Publish/refresh a room's NIP-99 listing. Returns event id to persist - on room.listing_event_id.""" + """Publish/refresh a room's NIP-99 kind:30402 listing (public).""" return await _sign_and_publish(events.build_listing_event(room)) async def publish_reservation(booking: Booking) -> str | None: - """Publish the guest's encrypted kind:30078 reservation object. Called - on every status transition so the guest's durable copy stays current.""" + """Publish the guest's kind:30078 reservation object, NIP-44 encrypted to + them. Soft-fails on a LocalSigner (no server-side encrypt) until bunker.""" 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: +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).""" settings = await crud.get_or_create_settings() if not settings.publish_availability: return None @@ -89,26 +144,78 @@ async def publish_block_calendar(room: Room, start: str, end: str, block_id: str ) -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) +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) + ) -# --- inbound: guest-originated events --------------------------------------- +# --- inbound: availability queries (kind:22000) ----------------------------- async def subscribe_inbound() -> None: - """Long-running subscription for guest events on configured relays: + """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) - * 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) - SKETCH — no relay connection yet. Started as a permanent task from - __init__.py:chatelet_start() once relay plumbing lands. +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" + ) - 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)") + 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) -- 2.53.0 From 1465a30a01fdaa27f078bf87fedc17e07782c114 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 18:09:35 +0200 Subject: [PATCH 2/3] test: event-builder shape (30402/30078/31923/22001) Pure tests (no signing/relays): listing tags (d/title/price/status/g/t), reservation carries canonical amount_sat as plaintext JSON, calendar start/end + no broken 'a' tag, availability response plaintext. 20 pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- tests/test_events.py | 90 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/test_events.py diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..972e4b0 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,90 @@ +"""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 -- 2.53.0 From 7b8dd82a6e035e5d1367f1568362c813bca7dd35 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 18:09:35 +0200 Subject: [PATCH 3/3] docs: relay-transport (nostrclient) section + dependency note Document the in-process nostrclient publish/subscribe path, the public-vs- encrypted split (encrypted events await bunker/server-signing), and the soft runtime dependency on the nostrclient extension. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- README.md | 15 ++++++++++++--- docs/event-flow.md | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ae7536d..1ae8a1e 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,18 @@ 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:** 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. +> **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. ## Why not just reuse an existing NIP? diff --git a/docs/event-flow.md b/docs/event-flow.md index 6b51df2..26b5976 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -70,6 +70,33 @@ 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 -- 2.53.0