Wire relay publish + inbound subscription in nostr/service.py #2

Closed
opened 2026-07-18 22:19:40 +00:00 by padreug · 3 comments
Owner

The Nostr app-event layer is sketched but not live. nostr/service.py:

  • _sign_and_publish() signs/encrypts correctly (via resolve_signer) but the relay publish is TODO(relay) — events never reach a relay.
  • subscribe_inbound() just logs; no relay connection, no dispatch.

Work

  • Pick the relay transport (cheapest first):
    1. reuse the nostrclient extension's relay manager if installed;
    2. the core nostr_transport relay pool (lnbits #4);
    3. direct websockets fan-out like lnurlp/tasks.py:send_to_relay.
  • Implement outbound publish to settings.relays in _sign_and_publish; persist returned event ids (room.listing_event_id, booking.reservation_event_id).
  • Implement subscribe_inbound: subscribe on configured relays, NIP-44-decrypt via the operator signer, dispatch:
    • kind:22000 availability query → compute → respond_availability (kind:22001)
    • NIP-59 giftwrap booking request → same crud path as api_request_booking
  • Start it as a permanent task in chatelet_start() (currently commented out).

Acceptance

Publishing a room emits a real kind:30402 to relays; a guest availability query over kind:22000 gets a kind:22001 reply; a booking request over a giftwrap DM creates a held booking.

The Nostr app-event layer is sketched but not live. `nostr/service.py`: - `_sign_and_publish()` signs/encrypts correctly (via `resolve_signer`) but the relay publish is `TODO(relay)` — events never reach a relay. - `subscribe_inbound()` just logs; no relay connection, no dispatch. ### Work - [ ] Pick the relay transport (cheapest first): 1. reuse the `nostrclient` extension's relay manager if installed; 2. the core `nostr_transport` relay pool (lnbits #4); 3. direct `websockets` fan-out like `lnurlp/tasks.py:send_to_relay`. - [ ] Implement outbound publish to `settings.relays` in `_sign_and_publish`; persist returned event ids (`room.listing_event_id`, `booking.reservation_event_id`). - [ ] Implement `subscribe_inbound`: subscribe on configured relays, NIP-44-decrypt via the operator signer, dispatch: - `kind:22000` availability query → compute → `respond_availability` (`kind:22001`) - NIP-59 giftwrap booking request → same crud path as `api_request_booking` - [ ] Start it as a permanent task in `chatelet_start()` (currently commented out). ### Acceptance Publishing a room emits a real `kind:30402` to relays; a guest availability query over `kind:22000` gets a `kind:22001` reply; a booking request over a giftwrap DM creates a `held` booking.
Author
Owner

Reviewed nostrclient — correcting the "reuse the relay manager" option

Read nostrclient (router.py, nostr/, its CLAUDE.md). It is an always-on relay multiplexer, not an importable publish API. Architecture:

  • NostrClient singleton (router.py:14) owns a RelayManager; relay connections run on threads (RelayManager.open_connections()), client comms on asyncio, bridged via an executor in tasks.py. The nostr/ impl is the old python-nostr lineage (mypy-excluded), not async-native.
  • The intended consumption surface is a WebSocket relay endpoint: /nostrclient/api/v1/{ws_id} (private, encrypted id) and /nostrclient/api/v1/relay (public, if enabled). It fans out to configured relays and aggregates/dedupes.

Implication for this issue: "reuse the nostrclient relay manager in-process" (option 1 as originally written) is the wrong shape — it would couple Chatelet to nostrclient's threaded internals and require nostrclient always installed. The clean options are:

  1. Treat nostrclient as a loopback relay — Chatelet speaks the plain Nostr relay wire protocol to its …/api/v1/relay WS endpoint. Loose coupling: Chatelet stays relay-agnostic (can point at any relay), nostrclient's internals stay encapsulated. This is the protocol-over-loopback sweet spot from the workspace protocol-vs-IPC rule.
  2. Core nostr_transport relay pool (lnbits #4) — the same pool the RPC dispatcher in #1 uses. Preferred if we're doing #1 anyway, so app-event publish and RPC share one relay pool.
  3. Direct websockets fan-out (like lnurlp/tasks.py:send_to_relay) — simplest, no dependency, but we own reconnect/backoff.

Recommendation: since #1 targets the core nostr_transport, lean on option 2 (core pool) so publish + RPC share one relay connection, and keep the extension usable without nostrclient installed. Fall back to option 1 only if the core pool isn't exposed for arbitrary event publish.

Updating the checklist here rather than the body; will finalize the transport choice when #1/#2 are picked up together.

## Reviewed `nostrclient` — correcting the "reuse the relay manager" option Read `nostrclient` (`router.py`, `nostr/`, its CLAUDE.md). It is **an always-on relay _multiplexer_**, not an importable publish API. Architecture: - `NostrClient` singleton (`router.py:14`) owns a `RelayManager`; relay connections run on **threads** (`RelayManager.open_connections()`), client comms on asyncio, bridged via an executor in `tasks.py`. The `nostr/` impl is the old python-nostr lineage (mypy-excluded), not async-native. - The intended consumption surface is a **WebSocket relay endpoint**: `/nostrclient/api/v1/{ws_id}` (private, encrypted id) and `/nostrclient/api/v1/relay` (public, if enabled). It fans out to configured relays and aggregates/dedupes. **Implication for this issue:** "reuse the nostrclient relay manager in-process" (option 1 as originally written) is the wrong shape — it would couple Chatelet to nostrclient's threaded internals and require nostrclient always installed. The clean options are: 1. **Treat nostrclient as a loopback relay** — Chatelet speaks the plain Nostr relay wire protocol to its `…/api/v1/relay` WS endpoint. Loose coupling: Chatelet stays relay-agnostic (can point at any relay), nostrclient's internals stay encapsulated. This is the protocol-over-loopback sweet spot from the workspace protocol-vs-IPC rule. 2. **Core `nostr_transport` relay pool** (lnbits #4) — the same pool the RPC dispatcher in #1 uses. Preferred if we're doing #1 anyway, so app-event publish and RPC share one relay pool. 3. Direct `websockets` fan-out (like `lnurlp/tasks.py:send_to_relay`) — simplest, no dependency, but we own reconnect/backoff. **Recommendation:** since #1 targets the core `nostr_transport`, lean on **option 2** (core pool) so publish + RPC share one relay connection, and keep the extension usable without nostrclient installed. Fall back to option 1 only if the core pool isn't exposed for arbitrary event publish. Updating the checklist here rather than the body; will finalize the transport choice when #1/#2 are picked up together.
Author
Owner

Blocked on a transport decision — core pool can't do this

While building #1 I read the core transport internals (nostr_transport/relay_pool.py). Confirmed: the pool is a kind-21000 RPC bus onlyNostrTransportPool._publish_event hard-codes kind: 21000, and the relay subscription filters kinds: [21000]. There is no general publish-arbitrary-event API. So the "use the core pool for app-event publishing" recommendation from my earlier comment does not work — the core pool serves #1 (RPC) but not #2 (public NIP-99/78/52 discovery events).

This reframes #2 substantially

With #1 merged, the entire functional booking flow runs over the kind-21000 RPC (chatelet_room_list / _availability / _booking_request / _booking_get). So #2's public events are not needed for the app to work over Nostr — they're purely for open, third-party discoverability: letting someone browsing on a generic Nostr client (Amethyst, a NIP-99 market client) find and view a listing without speaking our RPC.

Two consequences:

  1. kind:22000/22001 (availability query/response) is now redundant — availability is an AUTH_NONE RPC. I'd drop the custom kinds and update ADR-0001 (and close #6, which registers them) unless we want non-RPC clients to query availability. Recommend dropping.
  2. The remaining #2 scope is only: publish kind:30402 listing + kind:31923 availability calendar (+ optional kind:30078 reservation) to public relays. That needs a relay path the core pool doesn't give us.

Options for the public-event path (if we want open discovery at all)

  • A — Defer/close #2. If open third-party discovery isn't a near-term goal for a castle-internal booking system, we don't need public events at all; the RPC + a webapp UI cover it. Cheapest.
  • B — Direct websockets fan-out (like lnurlp/tasks.py:send_zap), signing via resolve_signer. Simple, no dependency; we own reconnect/backoff for a handful of publishes.
  • C — nostrclient as a loopback relay — publish by connecting to its …/api/v1/relay WS endpoint. Reuses its relay management, but adds a hard dependency on nostrclient being installed.

Recommendation: A for now (the flow is complete over RPC after #1), and if/when open discovery is wanted, B — it keeps Chatelet self-contained and matches an existing pattern in the codebase. Reserve C only if we later want heavy relay fan-out managed centrally.

@padreug — how do you want to play #2: defer (A), or build the listing/calendar publish now (B)? And OK to drop kind:22000/22001 + close #6?

## Blocked on a transport decision — core pool can't do this While building #1 I read the core transport internals (`nostr_transport/relay_pool.py`). Confirmed: **the pool is a kind-21000 RPC bus only** — `NostrTransportPool._publish_event` hard-codes `kind: 21000`, and the relay subscription filters `kinds: [21000]`. There is **no general publish-arbitrary-event API**. So the "use the core pool for app-event publishing" recommendation from my earlier comment **does not work** — the core pool serves #1 (RPC) but not #2 (public NIP-99/78/52 discovery events). ### This reframes #2 substantially With #1 merged, the entire *functional* booking flow runs over the kind-21000 RPC (`chatelet_room_list` / `_availability` / `_booking_request` / `_booking_get`). So #2's public events are **not needed for the app to work over Nostr** — they're purely for **open, third-party discoverability**: letting someone browsing on a generic Nostr client (Amethyst, a NIP-99 market client) find and view a listing without speaking our RPC. Two consequences: 1. **`kind:22000/22001` (availability query/response) is now redundant** — availability is an `AUTH_NONE` RPC. I'd drop the custom kinds and update ADR-0001 (and close #6, which registers them) unless we want non-RPC clients to query availability. Recommend dropping. 2. **The remaining #2 scope is only: publish `kind:30402` listing + `kind:31923` availability calendar (+ optional `kind:30078` reservation) to public relays.** That needs a relay path the core pool doesn't give us. ### Options for the public-event path (if we want open discovery at all) - **A — Defer/close #2.** If open third-party discovery isn't a near-term goal for a castle-internal booking system, we don't need public events at all; the RPC + a webapp UI cover it. Cheapest. - **B — Direct `websockets` fan-out** (like `lnurlp/tasks.py:send_zap`), signing via `resolve_signer`. Simple, no dependency; we own reconnect/backoff for a handful of publishes. - **C — nostrclient as a loopback relay** — publish by connecting to its `…/api/v1/relay` WS endpoint. Reuses its relay management, but adds a hard dependency on nostrclient being installed. **Recommendation:** **A for now** (the flow is complete over RPC after #1), and if/when open discovery is wanted, **B** — it keeps Chatelet self-contained and matches an existing pattern in the codebase. Reserve C only if we later want heavy relay fan-out managed centrally. @padreug — how do you want to play #2: defer (A), or build the listing/calendar publish now (B)? And OK to drop `kind:22000/22001` + close #6?
Author
Owner

Resolved: the path is nostrclient (in-process), and #2 is NOT deferred

Researched the codebase (three passes). This supersedes my earlier "recommend A/defer, else B/direct-websockets" — B was wrong; the house pattern is nostrclient.

Findings

  • nostrmarket already depends on nostrclient — connects to its loopback WS relay endpoint (ws://localhost:{port}/nostrclient/api/v1/{encrypted "relay"}) and speaks raw EVENT/REQ/CLOSE frames. nostrclient owns all real relay connections (its python-nostr-lineage RelayManager).
  • spirekeeper (ours) uses nostrclient IN-PROCESS — imports the singleton and publishes arbitrary kinds directly:
    from nostrclient.router import nostr_client
    nostr_client.relay_manager.publish_message(json.dumps(["EVENT", signed_event]))
    
    Subscribe = relay_manager.add_subscription(id, filters) + poll NostrRouter.received_subscription_events[id]. nostrclient does not sign — caller pre-signs (we already do, via resolve_signer). publish_message is sync + thread-safe, callable straight from asyncio.
  • restaurant/tasks bundle their own relay sockets — the anti-pattern to avoid (duplicated reconnect logic).

Decision

Implement #2 via nostrclient, in-process, following the spirekeeper template — chatelet already copies spirekeeper's signer pattern, so this is consistent and reuses its reconnect/backoff/dedup instead of reinventing it. Concretely wires the existing nostr/service.py stubs:

  • _sign_and_publishresolve_signer sign, then nostr_client.relay_manager.publish_message(...) behind a lazy-import guard (no-op if nostrclient absent), publishing kind:30402 listing, kind:31923 calendar, kind:30078 reservation.
  • subscribe_inboundadd_subscription for kind:22000 (availability query) + booking-request DMs, poll + dispatch to the same services.py flow.

#2 stays (not deferred), and #6 stays open

Per the now-recorded client-agnostic doctrine, public discovery events + the kind:22000/22001 availability proposal are the destination, not optional. So: do NOT drop the custom kinds, and #6 (register the allocation) remains valid — we're keeping them. Sequencing: land after the functional flow (#4) is solid, since the booking flow already works over RPC (#1).

Adds a soft runtime dependency on the nostrclient extension (same as nostrmarket) — worth noting in config.json/README so operators enable it.

## Resolved: the path is `nostrclient` (in-process), and #2 is NOT deferred Researched the codebase (three passes). This supersedes my earlier "recommend A/defer, else B/direct-websockets" — **B was wrong; the house pattern is nostrclient.** ### Findings - **`nostrmarket` already depends on `nostrclient`** — connects to its loopback WS relay endpoint (`ws://localhost:{port}/nostrclient/api/v1/{encrypted "relay"}`) and speaks raw `EVENT`/`REQ`/`CLOSE` frames. nostrclient owns all real relay connections (its python-nostr-lineage `RelayManager`). - **`spirekeeper` (ours) uses nostrclient IN-PROCESS** — imports the singleton and publishes arbitrary kinds directly: ```python from nostrclient.router import nostr_client nostr_client.relay_manager.publish_message(json.dumps(["EVENT", signed_event])) ``` Subscribe = `relay_manager.add_subscription(id, filters)` + poll `NostrRouter.received_subscription_events[id]`. nostrclient does **not** sign — caller pre-signs (we already do, via `resolve_signer`). `publish_message` is sync + thread-safe, callable straight from asyncio. - **`restaurant`/`tasks` bundle their own relay sockets** — the anti-pattern to avoid (duplicated reconnect logic). ### Decision **Implement #2 via nostrclient, in-process, following the `spirekeeper` template** — chatelet already copies spirekeeper's signer pattern, so this is consistent and reuses its reconnect/backoff/dedup instead of reinventing it. Concretely wires the existing `nostr/service.py` stubs: - `_sign_and_publish` → `resolve_signer` sign, then `nostr_client.relay_manager.publish_message(...)` behind a lazy-import guard (no-op if nostrclient absent), publishing `kind:30402` listing, `kind:31923` calendar, `kind:30078` reservation. - `subscribe_inbound` → `add_subscription` for `kind:22000` (availability query) + booking-request DMs, poll + dispatch to the same `services.py` flow. ### #2 stays (not deferred), and #6 stays open Per the now-recorded **client-agnostic doctrine**, public discovery events + the `kind:22000/22001` availability proposal are the destination, not optional. So: **do NOT drop the custom kinds**, and **#6 (register the allocation) remains valid** — we're keeping them. Sequencing: land after the functional flow (#4) is solid, since the booking flow already works over RPC (#1). Adds a **soft runtime dependency on the `nostrclient` extension** (same as nostrmarket) — worth noting in `config.json`/README so operators enable it.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/chatelet#2
No description provided.