diff --git a/README.md b/README.md index dee86a1..2006af6 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ flowchart LR 3. **Fan-Out** - Subscription requests are sent to all configured relays 4. **Aggregation** - Events from all relays are collected and deduplicated 5. **Response** - Events are sent back to the client with the original subscription ID -6. **Publish Acknowledgement** - Every `EVENT` a client publishes gets exactly one `["OK", , , ]` reply (NIP-01): `true` as soon as any relay accepts it, `false` once every relay has rejected it, the wait times out (10 s), or no relay is connected +6. **Publish Acknowledgement** - Every `EVENT` a client publishes gets exactly one `["OK", , , ]` reply (NIP-01): `true` as soon as any relay accepts it, `false` once every relay has rejected it, the wait times out (10 s), or no relay is connected. A `REQ` while no relay is configured at all is answered with `CLOSED` ## Configuration diff --git a/router.py b/router.py index 53ce387..8446512 100644 --- a/router.py +++ b/router.py @@ -195,7 +195,7 @@ class NostrRouter: assert len(json_data), "Bad JSON array" if json_data[0] == "REQ": - self._handle_client_req(json_data) + await self._handle_client_req(json_data) return if json_data[0] == "CLOSE": @@ -223,15 +223,29 @@ class NostrRouter: self.pending_publishes[event_id] = PendingPublish(event_id, len(connected)) nostr_client.relay_manager.publish_message(json_str) - def _handle_client_req(self, json_data): + async def _handle_client_req(self, json_data): subscription_id = json_data[1] logger.info(f"New subscription: '{subscription_id}'") + if not nostr_client.relay_manager.relays: + # NIP-01: nothing will ever serve this subscription, so refuse it. + # Only when no relay is configured at all: a configured relay that + # is merely reconnecting gets the cached subscription replayed. + await self._send_closed(subscription_id, "error: no relay connections") + return subscription_id_rewritten = urlsafe_short_hash() self.original_subscription_ids[subscription_id_rewritten] = subscription_id filters = json_data[2:] nostr_client.relay_manager.add_subscription(subscription_id_rewritten, filters) + async def _send_closed(self, subscription_id: str, message: str): + try: + await self.websocket.send_text( + json.dumps(["CLOSED", subscription_id, message]) + ) + except Exception as e: + logger.debug(f"Failed to send CLOSED for '{subscription_id}': {e}") + def _handle_client_close(self, subscription_id): subscription_id_rewritten = next( ( diff --git a/tests/test_router_ok.py b/tests/test_router_ok.py index 44863a1..e13301a 100644 --- a/tests/test_router_ok.py +++ b/tests/test_router_ok.py @@ -25,10 +25,14 @@ class FakeRelayManager: def __init__(self, relays: dict[str, FakeRelay]): self.relays = relays self.published: list[str] = [] + self.subscriptions: list[str] = [] def publish_message(self, message: str): self.published.append(message) + def add_subscription(self, subscription_id: str, filters): + self.subscriptions.append(subscription_id) + def close_subscriptions(self, subscriptions): pass @@ -139,3 +143,25 @@ async def test_stop_drops_pending_publishes(monkeypatch): assert router.pending_publishes == {} assert NostrRouter.received_command_results == {} + + +@pytest.mark.asyncio +async def test_req_without_configured_relays_is_closed(monkeypatch): + router, ws, manager = _router(monkeypatch, {}) + + await router._handle_client_to_nostr(json.dumps(["REQ", "sub1", {"kinds": [1]}])) + + assert ws.sent == [["CLOSED", "sub1", "error: no relay connections"]] + assert router.subscriptions == [] + assert manager.subscriptions == [] + + +@pytest.mark.asyncio +async def test_req_with_configured_but_disconnected_relay_is_kept(monkeypatch): + router, ws, manager = _router(monkeypatch, {"wss://a": FakeRelay(False)}) + + await router._handle_client_to_nostr(json.dumps(["REQ", "sub1", {"kinds": [1]}])) + + assert ws.sent == [] + assert len(router.subscriptions) == 1 + assert len(manager.subscriptions) == 1