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:
parent
f0c24ebba6
commit
900b682986
4 changed files with 264 additions and 0 deletions
4
nostr/__init__.py
Normal file
4
nostr/__init__.py
Normal 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
119
nostr/events.py
Normal 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
27
nostr/kinds.py
Normal 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
114
nostr/service.py
Normal 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)")
|
||||
Loading…
Add table
Add a link
Reference in a new issue