feat: nostr event layer (builders + sign/publish service)

Implements ADR-0001. kinds.py pins the allocations; events.py has pure
builders (30402 listing, 30078 encrypted reservation, 31923 blocked-range
calendar, 22001 availability response); service.py signs/encrypts via
resolve_signer (spirekeeper hybrid pattern — no nsec at rest) and sketches
publish + inbound subscription. Relay plumbing marked TODO(relay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
Padreug 2026-07-19 00:16:39 +02:00
commit 900b682986
4 changed files with 264 additions and 0 deletions

4
nostr/__init__.py Normal file
View file

@ -0,0 +1,4 @@
"""Chatelet's Nostr layer: event kind allocations, event builders, and the
sign/publish/subscribe service. Kept in its own package so the booking
logic (crud.py) stays transport-agnostic and the REST + Nostr entry points
can share it."""

119
nostr/events.py Normal file
View file

@ -0,0 +1,119 @@
"""Builders that turn Chatelet rows into unsigned Nostr events.
These are pure functions: they return unsigned event dicts (kind, tags,
content) with no pubkey/id/sig. Signing + NIP-44 encryption happen in
service.py via lnbits.core.signers.resolve_signer, so no nsec is handled
here and the same code works with a LocalSigner today or a NIP-46 bunker
later. See docs/event-flow.md for who publishes what, when.
"""
import json
from ..models import AvailabilityResult, Booking, Room
from .kinds import (
KIND_AVAILABILITY_RESPONSE,
KIND_CALENDAR_EVENT,
KIND_LISTING,
KIND_RESERVATION,
)
def build_listing_event(room: Room) -> dict:
"""NIP-99 kind:30402 classified listing for a room. Public, signed by
the operator's identity. `d` == room.id so re-publishing replaces."""
tags = [
["d", room.id],
["title", room.title],
["price", str(room.price_amount), room.price_currency, room.price_frequency],
["status", "active"],
]
if room.location:
tags.append(["location", room.location])
if room.geohash:
tags.append(["g", room.geohash])
for url in room.images:
tags.append(["image", url])
for amenity in room.amenities:
tags.append(["t", amenity])
return {
"kind": KIND_LISTING,
"content": room.description,
"tags": tags,
}
def build_reservation_event(booking: Booking, guest_pubkey: str) -> dict:
"""NIP-78 kind:30078 reservation object — the guest's durable, private
copy of their booking. `content` MUST be NIP-44 encrypted to the guest
by the caller (service.py) before signing; here we return the plaintext
payload so the signer can seal it. `d` == booking.id, addressable so
status transitions replace in place.
amount_sat is copied verbatim from the booking (canonical value) do
not recompute it here.
"""
payload = {
"booking_id": booking.id,
"room_id": booking.room_id,
"check_in": booking.check_in,
"check_out": booking.check_out,
"nights": booking.nights,
"num_guests": booking.num_guests,
"status": booking.status.value,
"amount_sat": booking.amount_sat,
"deposit_sat": booking.deposit_sat,
"currency": booking.currency,
"price_fiat": booking.price_fiat,
}
return {
"kind": KIND_RESERVATION,
# 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
}
def build_block_calendar_event(
room: Room, start_date: str, end_date: str, block_id: str
) -> dict:
"""NIP-52 kind:31923 marking a range as unavailable on the room's public
calendar. Deliberately carries NO guest PII just 'these dates are
taken'. Only published when settings.publish_availability is true."""
return {
"kind": KIND_CALENDAR_EVENT,
"content": "",
"tags": [
["d", f"{room.id}:{block_id}"],
["title", f"{room.title} — unavailable"],
["start", start_date],
["end", end_date],
["a", f"{KIND_LISTING}:{{operator_pubkey}}:{room.id}"], # link to listing
],
}
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."""
payload = {
"room_id": result.room_id,
"check_in": result.check_in,
"check_out": result.check_out,
"available": result.available,
"nights": result.nights,
"quote_sat": result.quote_sat,
"quote_fiat": result.quote_fiat,
"currency": result.currency,
}
return {
"kind": KIND_AVAILABILITY_RESPONSE,
"content": json.dumps(payload),
"tags": [["p", requester_pubkey]],
"_plaintext": payload,
}

27
nostr/kinds.py Normal file
View file

@ -0,0 +1,27 @@
"""Nostr event-kind allocations used by Chatelet.
Rationale for each choice lives in docs/adr-0001-nostr-event-model.md.
Short version: reuse standard NIPs wherever one fits the semantics, and
only reach into the aiolabs custom band for the live availability RPC that
no standard kind models well.
Aiolabs custom band is 22000-22099 (see workspace CLAUDE.md). The CLINK
band 21001-21099 is OFF-LIMITS. 22000/22001 are hereby allocated to
Chatelet register them in workspace CLAUDE.md before this ships so a
future extension doesn't collide.
"""
# --- standard NIPs (preferred) ---
KIND_LISTING = 30402 # NIP-99 classified listing (the room). Addressable.
KIND_RESERVATION = 30078 # NIP-78 app-specific data: the guest's durable
# reservation object, NIP-44 encrypted. Addressable
# so status updates (confirmed->checked_in->...) replace
# in place under the same d-tag (booking id).
KIND_CALENDAR = 31924 # NIP-52 calendar aggregating a room's blocked ranges.
KIND_CALENDAR_EVENT = 31923 # NIP-52 time-based event: one blocked range (no PII).
KIND_GIFTWRAP = 1059 # NIP-59 giftwrap carrying the private booking DMs
# (request -> quote -> confirm -> check-in details).
# --- aiolabs custom band (only where no standard kind fits) ---
KIND_AVAILABILITY_QUERY = 22000 # ephemeral: "is room X free for [in,out)?"
KIND_AVAILABILITY_RESPONSE = 22001 # ephemeral: open/closed + non-binding quote

114
nostr/service.py Normal file
View file

@ -0,0 +1,114 @@
"""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, 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.
"""
from loguru import logger
from .. import crud
from ..models import AvailabilityResult, Booking, Room
from . import events
try:
from lnbits.core.crud import get_account
from lnbits.core.signers import resolve_signer
_SIGNER_AVAILABLE = True
except ImportError: # pre-#17 lnbits — Nostr layer soft-disables
_SIGNER_AVAILABLE = False
async def _operator_signer():
"""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.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:
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.
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
plaintext = unsigned.pop("_plaintext", None)
if encrypt_to and plaintext is not None:
unsigned["content"] = await signer.nip44_encrypt(encrypt_to, unsigned["content"])
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 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 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:
settings = await crud.get_or_create_settings()
if not settings.publish_availability:
return None
return await _sign_and_publish(
events.build_block_calendar_event(room, start, end, block_id)
)
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: guest-originated events ---------------------------------------
async def subscribe_inbound() -> None:
"""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)
SKETCH no relay connection yet. Started as a permanent task from
__init__.py:chatelet_start() once relay plumbing lands.
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)")