feat: Nostr relay publish + availability subscription via nostrclient (#2) #11

Merged
padreug merged 3 commits from feat/nostr-relay-publish into main 2026-07-19 17:33:34 +00:00
6 changed files with 311 additions and 66 deletions

View file

@ -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 rooms, take booking requests, arbitrate availability, and settle stays over
Lightning, with Nostr as the interop layer. Lightning, with Nostr as the interop layer.
> **Status:** design sketch + scaffold. Data model, migrations, availability > **Status:** functional prototype. Data model, migrations, availability
> arbiter, REST surface, and Nostr event model are in place; relay plumbing, > arbiter (atomic hold), FX + invoice + settlement, the REST + kind-21000 RPC
> FX/invoice wiring, and the guest UI are stubbed with `TODO(...)` markers. > 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? ## Why not just reuse an existing NIP?

View file

@ -36,6 +36,15 @@ def chatelet_start():
create_permanent_unique_task("chatelet_hold_expiry", expire_holds_loop) 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 # 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 # RPC) so an HTTP-allergic client can drive Chatelet over relays. Also wire
# the booking-owner resolver so the operator can stream booking # the booking-owner resolver so the operator can stream booking

View file

@ -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) | | `1059` | 17/59 | both | Private booking DMs (request → quote → confirm → check-in) |
| `22000/22001` | aiolabs | guest/operator | Live availability query + response (ephemeral) | | `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 ## Happy path
```mermaid ```mermaid

View file

@ -67,13 +67,13 @@ def build_reservation_event(booking: Booking, guest_pubkey: str) -> dict:
} }
return { return {
"kind": KIND_RESERVATION, "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), "content": json.dumps(payload),
"tags": [ "tags": [
["d", booking.id], ["d", booking.id],
["p", guest_pubkey], ["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"], ["title", f"{room.title} — unavailable"],
["start", start_date], ["start", start_date],
["end", end_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( def build_availability_response(
result: AvailabilityResult, requester_pubkey: str result: AvailabilityResult, requester_pubkey: str
) -> dict: ) -> dict:
"""Aiolabs kind:22001 (ephemeral) reply to an availability query. Content """Aiolabs kind:22001 (ephemeral) reply to an availability query.
MUST be NIP-44 encrypted to the requester by service.py."""
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 = { payload = {
"room_id": result.room_id, "room_id": result.room_id,
"check_in": result.check_in, "check_in": result.check_in,
@ -115,5 +119,4 @@ def build_availability_response(
"kind": KIND_AVAILABILITY_RESPONSE, "kind": KIND_AVAILABILITY_RESPONSE,
"content": json.dumps(payload), "content": json.dumps(payload),
"tags": [["p", requester_pubkey]], "tags": [["p", requester_pubkey]],
"_plaintext": payload,
} }

View file

@ -1,86 +1,141 @@
"""Nostr I/O for Chatelet: sign+publish outbound events, subscribe to """Nostr I/O for Chatelet: sign + publish outbound events, and subscribe to
inbound guest requests. SKETCH the relay plumbing is stubbed; the shape inbound availability queries over the `nostrclient` extension's relay
and the signer/encryption boundaries are what matter here. manager, in-process (the spirekeeper pattern, aiolabs/spirekeeper
nostr_publish.py + tasks.py).
Signing + NIP-44 go through lnbits.core.signers.resolve_signer, following Signing + NIP-44 go through `lnbits.core.signers.resolve_signer`: the
the spirekeeper hybrid pattern (aiolabs/spirekeeper nostr_publish.py): operator account's signer produces the signature and (for encrypted events)
resolve the operator account's signer, then sign_event / nip44_encrypt. the NIP-44 ciphertext. No nsec is read here works with a server-signing /
No nsec is ever read here works with LocalSigner now, NIP-46 bunker NIP-46 bunker signer. A LocalSigner can `sign_event` but its `nip44_encrypt`
after lnbits#18. If the signer module isn't present (pre-#17 lnbits), the raises (bunker-forward by design), so **public** events (listing 30402,
whole Nostr layer soft-disables and Chatelet still works over REST. 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 loguru import logger
from .. import crud from .. import crud, services
from ..models import AvailabilityResult, Booking, Room from ..models import Booking, Room
from . import events from . import events
from .kinds import KIND_AVAILABILITY_QUERY
try: try:
from lnbits.core.crud import get_account from lnbits.core.crud import get_account
from lnbits.core.signers import resolve_signer from lnbits.core.signers import resolve_signer
from lnbits.core.signers.base import SignerUnavailableError
_SIGNER_AVAILABLE = True _SIGNER_AVAILABLE = True
except ImportError: # pre-#17 lnbits — Nostr layer soft-disables except ImportError: # pre-signer lnbits — Nostr layer soft-disables
_SIGNER_AVAILABLE = False _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(): async def _operator_signer():
"""Resolve the operator account + its signer, or (None, None) if the """(account, signer) for the configured operator, or (None, None) if the
Nostr layer is unavailable / not onboarded.""" Nostr layer is unavailable / no operator onboarded."""
if not _SIGNER_AVAILABLE: if not _SIGNER_AVAILABLE:
return None, None return None, None
settings = await crud.get_or_create_settings() settings = await crud.get_or_create_settings()
if not settings.operator_id: 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 return None, None
account = await get_account(settings.operator_id) account = await get_account(settings.operator_id)
if not account or not account.pubkey: if not account or not account.pubkey:
logger.warning("chatelet: operator account has no Nostr pubkey; skipping")
return None, None return None, None
return account, resolve_signer(account) return account, resolve_signer(account)
async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None: # --- outbound: sign + publish -----------------------------------------------
"""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; async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) -> str | None:
2. the core nostr_transport relay pool (lnbits#4); """Encrypt (if `encrypt_to`), sign as the operator, and publish via
3. a direct websockets fan-out like lnurlp/tasks.py send_to_relay. nostrclient. Returns the event id, or None on any soft-fail."""
"""
_account, signer = await _operator_signer() _account, signer = await _operator_signer()
if not signer: if not signer:
return None return None
plaintext = unsigned.pop("_plaintext", None) if encrypt_to:
if encrypt_to and plaintext is not None: try:
unsigned["content"] = await signer.nip44_encrypt(encrypt_to, unsigned["content"]) 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 unsigned["created_at"] = int(time.time()) # part of the event-id hash
# TODO(relay): await relay_pool.publish(signed, settings.relays) try:
logger.debug(f"chatelet: (would) publish kind={signed['kind']} id={signed.get('id')}") 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") return signed.get("id")
# --- outbound: operator-published events -----------------------------------
async def publish_listing(room: Room) -> str | None: async def publish_listing(room: Room) -> str | None:
"""Publish/refresh a room's NIP-99 listing. Returns event id to persist """Publish/refresh a room's NIP-99 kind:30402 listing (public)."""
on room.listing_event_id."""
return await _sign_and_publish(events.build_listing_event(room)) return await _sign_and_publish(events.build_listing_event(room))
async def publish_reservation(booking: Booking) -> str | None: async def publish_reservation(booking: Booking) -> str | None:
"""Publish the guest's encrypted kind:30078 reservation object. Called """Publish the guest's kind:30078 reservation object, NIP-44 encrypted to
on every status transition so the guest's durable copy stays current.""" them. Soft-fails on a LocalSigner (no server-side encrypt) until bunker."""
unsigned = events.build_reservation_event(booking, booking.guest_pubkey) unsigned = events.build_reservation_event(booking, booking.guest_pubkey)
return await _sign_and_publish(unsigned, encrypt_to=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() settings = await crud.get_or_create_settings()
if not settings.publish_availability: if not settings.publish_availability:
return None 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: async def respond_availability(result, requester_pubkey: str) -> str | None:
unsigned = events.build_availability_response(result, requester_pubkey) """Publish a kind:22001 availability response (plaintext, public info)."""
return await _sign_and_publish(unsigned, encrypt_to=requester_pubkey) 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: 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 async def _availability_tick(registered: bool) -> bool:
__init__.py:chatelet_start() once relay plumbing lands. 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, inbound = NostrRouter.received_subscription_events.get(_AVAILABILITY_SUB_ID)
dispatch to handlers that reuse the same crud paths as the REST API so while inbound:
HTTP and Nostr entry points converge on one booking flow. event_message = inbound.pop(0)
""" try:
logger.info("chatelet: inbound Nostr subscription not yet wired (sketch)") 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)

90
tests/test_events.py Normal file
View file

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