diff --git a/README.md b/README.md index 25fc25c2..861cad8f 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ sequenceDiagram - **API Key Management** – Hashed keys stored in SQLite with balance tracking and optional expiry/refund address - **Model-Based Pricing** – Convert USD prices in `models.json` to sats using live BTC/USD rates - **Admin Dashboard** – Simple HTML interface at `/admin/` to view balances and API keys -- **Discovery** – Fetch available providers from Nostr relays +- **Discovery** – Fetch available providers from Nostr relays using NIP-91 protocol +- **NIP-91 Auto-Announcement** – Automatically announce this provider to Nostr relays when NSEC is provided - **Docker Support** – Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service ## Getting Started @@ -86,6 +87,8 @@ This builds the image and also starts a Tor container exposing the API as a hidd The most common settings are shown below. See `.env.example` for the full list. +### Core Settings + - `UPSTREAM_BASE_URL` – URL of the OpenAI-compatible service - `UPSTREAM_API_KEY` – API key for the upstream service (optional) - `MODEL_BASED_PRICING` – Set to `true` to use pricing from `models.json` diff --git a/compose.yml b/compose.yml index 289c30ce..aabf8d62 100644 --- a/compose.yml +++ b/compose.yml @@ -6,6 +6,7 @@ services: volumes: - .:/app - ./logs:/app/logs + - tor-data:/var/lib/tor:ro env_file: - .env environment: diff --git a/docs/advanced/nostr.md b/docs/advanced/nostr.md index 4d7a124a..e3aebcb1 100644 --- a/docs/advanced/nostr.md +++ b/docs/advanced/nostr.md @@ -5,6 +5,7 @@ Routstr Core integrates with Nostr (Notes and Other Stuff Transmitted by Relays) ## Overview Nostr integration provides: + - **Decentralized Discovery**: Find providers through relay network - **Cryptographic Identity**: Providers identified by public keys - **Real-time Updates**: Live provider status and pricing @@ -34,6 +35,7 @@ Providers announce themselves by publishing signed events to Nostr relays. Clien ### Setting Up Nostr Identity 1. **Generate Nostr Keys** + ```bash # Using nostril or similar tool nostril --generate-keypair @@ -44,6 +46,7 @@ Providers announce themselves by publishing signed events to Nostr relays. Clien ``` 2. **Configure Environment** + ```bash # .env NPUB=npub1xyz... # Your public key @@ -104,7 +107,7 @@ DEFAULT_RELAYS = [ ] # Custom relay configuration -NOSTR_RELAYS=wss://relay1.com,wss://relay2.com +RELAYS=wss://relay1.com,wss://relay2.com ``` ## Client Discovery @@ -276,6 +279,7 @@ def select_provider( ### Automatic Updates Routstr publishes updates when: + - Node starts up - Configuration changes - Models are added/removed @@ -295,7 +299,7 @@ async def publish_provider_info(): pricing=get_current_pricing() ) - await publish_to_relays(event, NOSTR_RELAYS) + await publish_to_relays(event, RELAYS) ``` ### Event Lifecycle @@ -315,7 +319,7 @@ async def update_nostr_presence(): async def remove_nostr_presence(): """Remove provider from discovery.""" deletion_event = create_deletion_event() - await publish_to_relays(deletion_event, NOSTR_RELAYS) + await publish_to_relays(deletion_event, RELAYS) ``` ## Security Considerations @@ -323,6 +327,7 @@ async def remove_nostr_presence(): ### Key Management 1. **Secure Storage** + ```python # Never log private keys SENSITIVE_VARS = ['NSEC', 'ADMIN_PASSWORD'] @@ -335,6 +340,7 @@ async def remove_nostr_presence(): ``` 2. **Key Rotation** + ```bash # Generate new keys nostril --generate-keypair @@ -524,7 +530,7 @@ async def debug_discovery(): issues = [] # Check relay connectivity - for relay in NOSTR_RELAYS: + for relay in RELAYS: if not await can_connect_to_relay(relay): issues.append(f"Cannot connect to {relay}") @@ -589,4 +595,4 @@ wscat -c wss://relay.damus.io - [Tor Support](tor.md) - Anonymous provider access - [Custom Pricing](custom-pricing.md) - Dynamic pricing strategies -- [API Reference](../api/endpoints.md) - Discovery API details \ No newline at end of file +- [API Reference](../api/endpoints.md) - Discovery API details diff --git a/pyproject.toml b/pyproject.toml index 9e7f7fca..b65c1c95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ dependencies = [ "cashu", "secp256k1", "marshmallow>=3.13,<4.0", + "websockets>=12.0", + "nostr>=0.0.2", ] [dependency-groups] diff --git a/routstr/core/logging.py b/routstr/core/logging.py index 00a8ad27..d7ec38e5 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -293,14 +293,19 @@ def setup_logging() -> None: "handlers": ["console"] if console_enabled else [], "propagate": False, }, + "websockets": { + "level": "WARNING", + "handlers": [], + "propagate": False, + }, "uvicorn.access": { - "level": log_level, # Use the configured log level instead of WARNING - "handlers": handlers, # Use both console and file handlers + "level": "WARNING", + "handlers": ["file"], "propagate": False, }, "uvicorn.error": { - "level": log_level, # Use the configured log level - "handlers": handlers, # Use both console and file handlers + "level": log_level, + "handlers": handlers, "propagate": False, }, "watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False}, diff --git a/routstr/core/main.py b/routstr/core/main.py index b8a19bbd..76f396e2 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -9,7 +9,8 @@ from fastapi.responses import RedirectResponse from starlette.exceptions import HTTPException from ..balance import balance_router, deprecated_wallet_router -from ..discovery import providers_router +from ..discovery import providers_cache_refresher, providers_router +from ..nip91 import announce_provider from ..payment.models import MODELS, models_router, update_sats_pricing from ..proxy import proxy_router from ..wallet import periodic_payout @@ -32,6 +33,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: pricing_task = None payout_task = None + nip91_task = None + providers_task = None try: # Run database migrations on startup @@ -46,6 +49,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: pricing_task = asyncio.create_task(update_sats_pricing()) payout_task = asyncio.create_task(periodic_payout()) + nip91_task = asyncio.create_task(announce_provider()) + providers_task = asyncio.create_task(providers_cache_refresher()) yield @@ -62,6 +67,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: pricing_task.cancel() if payout_task is not None: payout_task.cancel() + if nip91_task is not None: + nip91_task.cancel() + if providers_task is not None: + providers_task.cancel() try: tasks_to_wait = [] @@ -69,6 +78,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(pricing_task) if payout_task is not None: tasks_to_wait.append(payout_task) + if nip91_task is not None: + tasks_to_wait.append(nip91_task) + if providers_task is not None: + tasks_to_wait.append(providers_task) if tasks_to_wait: await asyncio.gather(*tasks_to_wait, return_exceptions=True) diff --git a/routstr/discovery.py b/routstr/discovery.py index caa77e85..798c1756 100644 --- a/routstr/discovery.py +++ b/routstr/discovery.py @@ -15,6 +15,10 @@ logger = get_logger(__name__) providers_router = APIRouter(prefix="/v1/providers") +# In-memory providers cache and lock +_PROVIDERS_CACHE: list[dict[str, Any]] = [] +_PROVIDERS_CACHE_LOCK = asyncio.Lock() + def generate_subscription_id() -> str: """Generate a random subscription ID.""" @@ -28,14 +32,14 @@ async def query_nostr_relay_for_providers( timeout: int = 30, ) -> list[dict[str, Any]]: """ - Query a Nostr relay for provider announcements using RIP-02 spec. - Searches for kind 31338 events (Routstr Provider Announcements). + Query a Nostr relay for provider announcements. + Searches for NIP-91 (kind:38421) events. """ events = [] - # Build filter according to RIP-02 spec + # Build filter for NIP-91 events filter_obj: dict[str, Any] = { - "kinds": [31338], # RIP-02 Provider Announcement events + "kinds": [38421], # NIP-91 Provider Announcements "limit": limit, } @@ -47,8 +51,8 @@ async def query_nostr_relay_for_providers( req_message = json.dumps(["REQ", sub_id, filter_obj]) try: - async with websockets.connect(relay_url, timeout=timeout) as websocket: - logger.debug("Connected to relay, searching for kind 31338 events") + async with websockets.connect(relay_url, open_timeout=timeout) as websocket: + logger.debug("Connected to relay, searching for NIP-91 events (kind 38421)") await websocket.send(req_message) while True: @@ -64,7 +68,13 @@ async def query_nostr_relay_for_providers( logger.debug("Received EOSE message") break elif data[0] == "NOTICE": - logger.warning(f"Relay notice: {data[1]}") + try: + msg = str(data[1]) + if len(msg) > 200: + msg = msg[:200] + "..." + logger.debug(f"Relay notice: {msg}") + except Exception: + logger.debug("Relay notice received") except asyncio.TimeoutError: logger.debug("Timeout waiting for message") @@ -76,7 +86,7 @@ async def query_nostr_relay_for_providers( await websocket.send(json.dumps(["CLOSE", sub_id])) except Exception as e: - logger.error(f"Query failed: {e}") + logger.debug(f"Query failed: {type(e).__name__}") logger.info(f"Query complete. Found {len(events)} provider announcements") return events @@ -84,17 +94,19 @@ async def query_nostr_relay_for_providers( def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None: """ - Parse a kind 31338 provider announcement event according to RIP-02 spec. + Parse provider announcement events. + Handles NIP-91 (kind:38421) format. Returns structured provider data or None if invalid. """ try: - # Extract required tags according to RIP-02 tags = event.get("tags", []) + kind = event.get("kind") - # Find required tags - endpoint_url = None - provider_name = None + # Common fields d_tag = None + endpoint_urls = [] + provider_name = None + endpoint_url = None for tag in tags: if len(tag) >= 2: @@ -105,8 +117,8 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None: elif tag[0] == "d": d_tag = tag[1] - # Validate required fields - if not endpoint_url or not provider_name or not d_tag: + # Early validation only applies to legacy/other kinds, not NIP-91 + if kind != 38421 and (not endpoint_url or not provider_name or not d_tag): logger.warning( f"Invalid provider announcement - missing required tags: {event['id']}" ) @@ -114,46 +126,194 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None: # Extract optional tags description = None - contact = None - pricing_url = None - supported_models = [] + mint_urls = [] + version = None - for tag in tags: - if len(tag) >= 2: - if tag[0] == "description": - description = tag[1] - elif tag[0] == "contact": - contact = tag[1] - elif tag[0] == "pricing": - pricing_url = tag[1] - elif tag[0] == "model": - supported_models.append(tag[1]) + # Parse NIP-91 format + if kind == 38421: # NIP-91 format + for tag in tags: + if len(tag) >= 2: + if tag[0] == "d": + d_tag = tag[1] + elif tag[0] == "u": + endpoint_urls.append(tag[1]) + elif tag[0] == "mint": + mint_urls.append(tag[1]) + elif tag[0] == "version": + version = tag[1] + + # Parse metadata from content for NIP-91 + content = event.get("content", "") + if content: + try: + metadata = json.loads(content) + provider_name = metadata.get("name", "Unknown Provider") + description = metadata.get("about") + except (json.JSONDecodeError, TypeError): + provider_name = "Unknown Provider" + else: + provider_name = "Unknown Provider" + + # Use first URL as primary endpoint + endpoint_url = endpoint_urls[0] if endpoint_urls else None + + # Validate NIP-91 required fields + if not endpoint_url or not d_tag: + logger.warning( + f"Invalid NIP-91 announcement - missing required fields: {event['id']}" + ) + return None + else: + logger.warning( + f"Unknown event kind when parsing provider announcement: {kind}" + ) + return None return { - "id": event["id"], + "id": d_tag, "pubkey": event["pubkey"], "created_at": event["created_at"], - "d_tag": d_tag, + "kind": kind, "endpoint_url": endpoint_url, + "endpoint_urls": endpoint_urls, # All URLs for NIP-91 "name": provider_name, "description": description, - "contact": contact, - "pricing_url": pricing_url, - "supported_models": supported_models, + "mint_urls": mint_urls, + "version": version, "content": event.get("content", ""), } except Exception as e: - logger.error(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}") + logger.error( + f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}" + ) return None async def get_cache() -> list[dict[str, Any]]: - return [] # TODO: Implement cache + async with _PROVIDERS_CACHE_LOCK: + return list(_PROVIDERS_CACHE) + + +def _get_discovery_relays() -> list[str]: + relays_env = os.getenv("RELAYS") or "" + discovery_relays = [r.strip() for r in relays_env.split(",") if r.strip()] + if not discovery_relays: + discovery_relays = [ + "wss://relay.nostr.band", + "wss://relay.damus.io", + "wss://relay.routstr.com", + ] + return discovery_relays + + +async def _discover_providers(pubkey: str | None = None) -> list[dict[str, Any]]: + discovery_relays = _get_discovery_relays() + + tasks = [ + query_nostr_relay_for_providers(relay_url=r, pubkey=pubkey, limit=100) + for r in discovery_relays + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + all_events: list[dict[str, Any]] = [] + event_ids: set[str] = set() + for res in results: + if isinstance(res, BaseException): + logger.error(f"Relay query failed: {res}") + continue + if isinstance(res, list): + for event in res: + # Filter out localhost announcements + try: + tags = event.get("tags", []) + is_localhost = any( + isinstance(tag, list) + and len(tag) >= 2 + and tag[0] == "u" + and tag[1] == "http://localhost:8000" + for tag in tags + ) + if is_localhost: + logger.debug( + f"Skipping localhost provider event: {event.get('id', 'unknown')}" + ) + continue + except Exception: + # If tags are malformed, fall through to normal handling + pass + + if (eid := event.get("id")) and eid not in event_ids: + event_ids.add(eid) + all_events.append(event) + else: + logger.error(f"Unexpected relay result type: {type(res)}") + + providers: list[dict[str, Any]] = [] + seen_endpoints: set[str] = set() + for event in all_events: + parsed = parse_provider_announcement(event) + if parsed and (eu := parsed.get("endpoint_url")) and eu not in seen_endpoints: + seen_endpoints.add(eu) + providers.append(parsed) + + random.shuffle(providers) + return providers[:42] + + +async def refresh_providers_cache(pubkey: str | None = None) -> None: + try: + providers = await _discover_providers(pubkey=pubkey) + + health_tasks = [ + fetch_provider_health(provider["endpoint_url"]) for provider in providers + ] + health_results = await asyncio.gather(*health_tasks, return_exceptions=True) + + new_cache: list[dict[str, Any]] = [] + for provider, hr in zip(providers, health_results): + if isinstance(hr, Exception): + health: dict[str, Any] = { + "status_code": 500, + "endpoint": "error", + "json": {"error": str(hr)}, + } + else: + health = hr # type: ignore[assignment] + new_cache.append({"provider": provider, "health": health}) + + async with _PROVIDERS_CACHE_LOCK: + _PROVIDERS_CACHE.clear() + _PROVIDERS_CACHE.extend(new_cache) + logger.info( + f"Providers cache refreshed with {len(new_cache)} entries (limit 42)" + ) + except Exception as e: + logger.error(f"Failed to refresh providers cache: {e}") + + +async def providers_cache_refresher( + interval_seconds: int | None = None, pubkey: str | None = None +) -> None: + if interval_seconds is None: + try: + interval_seconds = int( + os.getenv("PROVIDERS_REFRESH_INTERVAL_SECONDS", "300") + ) + except ValueError: + interval_seconds = 300 + + await refresh_providers_cache(pubkey=pubkey) + while True: + try: + await asyncio.sleep(interval_seconds) + except asyncio.CancelledError: + break + await refresh_providers_cache(pubkey=pubkey) async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]: - """Check if a provider endpoint is healthy by making a GET request.""" + """Fetch provider health and info, preferring /v1/info for models and pricing.""" try: # Determine if we need Tor proxy based on .onion domain is_onion = ".onion" in endpoint_url @@ -170,7 +330,20 @@ async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]: follow_redirects=True, proxies=proxies, # type: ignore[arg-type] ) as client: - # Try to fetch models endpoint first (common for AI providers) + # Prefer provider's /v1/info for full details + info_url = f"{endpoint_url.rstrip('/')}/v1/info" + try: + response = await client.get(info_url) + if response.status_code == 200: + return { + "status_code": response.status_code, + "endpoint": "info", + "json": response.json(), + } + except Exception: + pass + + # Fallback to /v1/models models_url = f"{endpoint_url.rstrip('/')}/v1/models" try: response = await client.get(models_url) @@ -208,65 +381,16 @@ async def get_providers( include_json: bool = False, pubkey: str | None = None ) -> dict[str, list[dict[str, Any]]]: """ - Discover Routstr providers using RIP-02 specification. - Searches for kind 31338 provider announcement events on Nostr relays. - - Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md + Return cached providers. If include_json, return provider+health; otherwise provider only. + Optional filter by pubkey. """ - # Default relays for provider discovery - discovery_relays = [ - "wss://relay.nostr.band", - "wss://relay.damus.io", - "wss://relay.routstr.com", - ] - - all_events = [] - event_ids = set() # To avoid duplicates - - # Query multiple relays for provider announcements - for relay_url in discovery_relays: - logger.info(f"Querying relay for providers: {relay_url}") - try: - events = await query_nostr_relay_for_providers( - relay_url=relay_url, - pubkey=pubkey, - limit=100, - ) - - # Add unique events - for event in events: - if event["id"] not in event_ids: - event_ids.add(event["id"]) - all_events.append(event) - - logger.info(f"Got {len(events)} provider announcements from {relay_url}") - - except Exception as e: - logger.error(f"Failed to query {relay_url}: {e}") - continue - - logger.info(f"Found {len(all_events)} total unique provider announcements") - - # Parse provider announcements according to RIP-02 - providers = [] - for event in all_events: - parsed_provider = parse_provider_announcement(event) - if parsed_provider: - providers.append(parsed_provider) - - logger.info(f"Parsed {len(providers)} valid provider announcements") - - # Check provider health if requested - healthy_providers: list[dict[str, Any]] = [] - for provider in providers: - endpoint_url = provider["endpoint_url"] - - if include_json: - health_check = await fetch_provider_health(endpoint_url) - provider_data = {"provider": provider, "health": health_check} - healthy_providers.append(provider_data) - else: - # Just return the provider info without health check - healthy_providers.append(provider) - - return {"providers": healthy_providers} + cache = await get_cache() + if not cache: + await refresh_providers_cache(pubkey=pubkey) + cache = await get_cache() + if pubkey: + cache = [c for c in cache if c.get("provider", {}).get("pubkey") == pubkey] + if include_json: + return {"providers": cache} + providers_only = [c["provider"] for c in cache] + return {"providers": providers_only} diff --git a/routstr/nip91.py b/routstr/nip91.py new file mode 100644 index 00000000..a5d7f4cc --- /dev/null +++ b/routstr/nip91.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +""" +NIP-91: Routstr Provider Discoverability Implementation +Automatically announces this Routstr proxy instance to Nostr relays. +""" + +import asyncio +import json +import os +import random +import ssl +import time +from typing import Any, cast + +from nostr.event import Event +from nostr.filter import Filter, Filters +from nostr.key import PrivateKey +from nostr.message_type import ClientMessageType +from nostr.relay_manager import RelayManager + +from .core import get_logger + +logger = get_logger(__name__) + + +def get_app_version() -> str | None: + try: + from .core.main import __version__ as imported_version + + return imported_version + except Exception: + return None + + +def _event_to_dict(ev: Event) -> dict[str, Any]: + return { + "id": ev.id, + "pubkey": ev.public_key, + "created_at": ev.created_at, + "kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind, + "tags": ev.tags, + "content": ev.content, + "sig": ev.signature, + } + + +def nsec_to_keypair(nsec: str) -> tuple[str, str] | None: + """ + Convert a Nostr private key (nsec) to a keypair (privkey_hex, pubkey_hex). + + Args: + nsec: Nostr private key in nsec format or hex format + + Returns: + Tuple of (private_key_hex, public_key_hex) or None if invalid + """ + try: + if nsec.startswith("nsec"): + pk = PrivateKey.from_nsec(nsec) + return (pk.hex(), pk.public_key.hex()) + + if len(nsec) == 64: + pk = PrivateKey(bytes.fromhex(nsec)) + return (pk.hex(), pk.public_key.hex()) + + logger.error(f"Invalid private key format/length: {len(nsec)}") + return None + except Exception as e: + logger.error(f"Failed to convert nsec to keypair: {e}") + return None + + +def create_nip91_event( + private_key_hex: str, + provider_id: str, + endpoint_urls: list[str], + mint_urls: list[str] | None = None, + version: str | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + Create a NIP-91 compliant provider announcement event (kind:38421). + + Args: + private_key_hex: 32-byte hex private key for signing + provider_id: Unique identifier for this provider (d tag) + endpoint_urls: List of URLs to connect to the provider + mint_urls: Optional list of ecash mint URLs for payments + version: Provider software version + metadata: Optional metadata dictionary (name, picture, about, etc.) + + Returns: + Complete signed nostr event as a dict ready for publishing + """ + pk = PrivateKey(bytes.fromhex(private_key_hex)) + + tags = [["d", provider_id]] + for url in endpoint_urls: + tags.append(["u", url]) + if mint_urls: + for m in mint_urls: + if m: + tags.append(["mint", m]) + if version: + tags.append(["version", version]) + + content = json.dumps(metadata, separators=(",", ":")) if metadata else "" + + ev = Event(pk.public_key.hex(), content, kind=38421, tags=tags) + pk.sign_event(ev) + return _event_to_dict(ev) + + +def _get_tag_values(event: dict[str, Any], key: str) -> list[str]: + tags = event.get("tags", []) + values: list[str] = [] + for tag in tags: + if isinstance(tag, list) and tag and tag[0] == key and len(tag) >= 2: + values.append(tag[1]) + return values + + +def _get_single_tag_value(event: dict[str, Any], key: str) -> str | None: + values = _get_tag_values(event, key) + return values[0] if values else None + + +def _parse_content_json(content: str) -> dict[str, Any]: + if not content: + return {} + try: + parsed = json.loads(content) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool: + if a.get("kind") != b.get("kind"): + return False + + if _get_single_tag_value(a, "d") != _get_single_tag_value(b, "d"): + return False + + urls_a = set(_get_tag_values(a, "u")) + urls_b = set(_get_tag_values(b, "u")) + if urls_a != urls_b: + return False + + mints_a = set(_get_tag_values(a, "mint")) + mints_b = set(_get_tag_values(b, "mint")) + if mints_a != mints_b: + return False + + if _get_single_tag_value(a, "version") != _get_single_tag_value(b, "version"): + return False + + content_a = _parse_content_json(cast(str, a.get("content", ""))) + content_b = _parse_content_json(cast(str, b.get("content", ""))) + if content_a != content_b: + return False + + return True + + +async def query_nip91_events( + relay_url: str, + pubkey: str, + provider_id: str | None = None, + timeout: int = 30, +) -> tuple[list[dict[str, Any]], bool]: + """ + Query a Nostr relay for NIP-91 provider announcements (kind:38421) via nostr library. + + Returns a tuple of (events, ok) where ok indicates whether the relay interaction + succeeded without transport-level errors. + """ + + def _sync_query() -> tuple[list[dict[str, Any]], bool]: + rm = RelayManager() + rm.add_relay(relay_url) + events_out: list[dict[str, Any]] = [] + ok = True + try: + rm.open_connections({"cert_reqs": ssl.CERT_NONE}) + time.sleep(1.0) + + flt = Filter(kinds=[38421], authors=[pubkey], limit=10) + filters = Filters([flt]) + sub_id = f"nip91_{int(time.time())}" + rm.add_subscription(sub_id, filters) + req: list[Any] = [ClientMessageType.REQUEST, sub_id] + req.extend(filters.to_json_array()) + rm.publish_message(json.dumps(req)) + + start = time.time() + last_event_ts = start + while time.time() - start < timeout: + drained = False + while rm.message_pool.has_events(): + drained = True + ev_msg = rm.message_pool.get_event() + ev = ev_msg.event + ev_dict = _event_to_dict(ev) + if provider_id is not None: + tags = ev_dict.get("tags", []) + if not any( + isinstance(t, list) + and len(t) >= 2 + and t[0] == "d" + and t[1] == provider_id + for t in tags + ): + continue + events_out.append(ev_dict) + logger.debug( + f"Found existing NIP-91 event: {ev_dict.get('id', '')}" + ) + if drained: + last_event_ts = time.time() + + while rm.message_pool.has_notices(): + notice = rm.message_pool.get_notice() + try: + content = getattr(notice, "content", notice) + s = str(content) + if len(s) > 200: + s = s[:200] + "..." + logger.debug(f"Relay notice: {s}") + except Exception: + pass + + if time.time() - last_event_ts > 2.5: + break + + time.sleep(0.1) + except Exception as e: + ok = False + logger.debug(f"Failed to query relay {relay_url}: {type(e).__name__}") + finally: + try: + rm.close_connections() + except Exception: + pass + return events_out, ok + + return await asyncio.to_thread(_sync_query) + + +def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None: + """Discover onion URL by reading Tor hidden service hostname files. + + Tries common paths first, then scans recursively for any 'hostname' file. + Returns an http URL like 'http://.onion' if found. + """ + common_candidates = [ + os.path.join(base_dir, "hs", "router", "hostname"), + os.path.join(base_dir, "hs", "ROUTER", "hostname"), + os.path.join(base_dir, "hidden_service", "hostname"), + ] + + for candidate in common_candidates: + try: + with open(candidate, "r", encoding="utf-8") as f: + host = f.readline().strip() + if host and host.endswith(".onion"): + return f"http://{host}" + except Exception: + pass + + try: + for root, _dirs, files in os.walk(base_dir): + if "hostname" in files: + path = os.path.join(root, "hostname") + try: + with open(path, "r", encoding="utf-8") as f: + host = f.readline().strip() + if host and host.endswith(".onion"): + return f"http://{host}" + except Exception: + continue + except Exception: + pass + + return None + + +async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str: + explicit = os.getenv("PROVIDER_ID") or os.getenv("NIP91_PROVIDER_ID") + if explicit: + logger.info(f"Using configured provider_id from env: {explicit}") + return explicit + + latest_event: dict[str, Any] | None = None + latest_ts = -1 + for relay_url in relay_urls: + try: + events, _ok = await query_nip91_events(relay_url, public_key_hex, None) + for ev in events: + ts = int(ev.get("created_at", 0)) + if ts > latest_ts: + latest_event = ev + latest_ts = ts + except Exception: + continue + + existing_d = _get_single_tag_value(latest_event, "d") if latest_event else None + if existing_d: + logger.info(f"Reusing existing provider_id from relay: {existing_d}") + return existing_d + + fallback = public_key_hex[:12] + logger.info(f"No existing provider_id found; using fallback: {fallback}") + return fallback + + +async def publish_to_relay( + relay_url: str, + event: dict[str, Any], + timeout: int = 30, +) -> bool: + """ + Publish a NIP-91 event to a nostr relay via nostr library. + """ + + def _sync_publish() -> bool: + rm = RelayManager() + rm.add_relay(relay_url) + try: + rm.open_connections({"cert_reqs": ssl.CERT_NONE}) + time.sleep(1.0) + # Publish the event as-is via publish_message to preserve signature + rm.publish_message(json.dumps(["EVENT", event])) + logger.debug(f"Sent NIP-91 event {event.get('id', '')} to {relay_url}") + time.sleep(1.0) + return True + except Exception as e: + logger.debug(f"Failed to publish to {relay_url}: {type(e).__name__}") + return False + finally: + try: + rm.close_connections() + except Exception: + pass + + return await asyncio.to_thread(_sync_publish) + + +async def announce_provider() -> None: + """ + Background task to announce this Routstr provider to Nostr relays. + Checks for existing announcements and creates new ones if needed. + """ + # Check for NSEC in environment (use NSEC only) + nsec = os.getenv("NSEC") + if not nsec: + logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement") + return + + # Convert NSEC to keypair + keypair = nsec_to_keypair(nsec) + if not keypair: + logger.error("Failed to parse NSEC, skipping NIP-91 announcement") + return + + private_key_hex, public_key_hex = keypair + logger.info(f"Using Nostr pubkey: {public_key_hex}") + + # Configure relays first (RELAYS only) + relay_urls_env = os.getenv("RELAYS") or "" + logger.debug(f"Configured relays: {relay_urls_env}") + relay_urls = [url.strip() for url in relay_urls_env.split(",") if url.strip()] + if not relay_urls: + relay_urls = [ + "wss://relay.nostr.band", + "wss://relay.damus.io", + "wss://nos.lol", + ] + + # Determine a stable provider_id + provider_id = await _determine_provider_id(public_key_hex, relay_urls) + logger.info(f"Using provider_id: {provider_id}") + + # Core settings only (no ROUTSTR_* vars) + base_url = os.getenv("HTTP_URL") + onion_url = os.getenv("ONION_URL") + if not onion_url: + discovered = discover_onion_url_from_tor() + if discovered: + onion_url = discovered + logger.info(f"Discovered onion URL via Tor volume: {onion_url}") + provider_name = os.getenv("NAME", "Routstr Proxy") + provider_about = os.getenv("DESCRIPTION", "Privacy-preserving AI proxy via Nostr") + # Mint URLs optional: include all CASHU_MINTS entries if available + cashu_mints = [ + m.strip() for m in os.getenv("CASHU_MINTS", "").split(",") if m.strip() + ] + mint_urls = cashu_mints if cashu_mints else None + + # Build endpoint URLs (skip defaults like localhost) + endpoint_urls: list[str] = [] + if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000": + endpoint_urls.append(base_url.strip()) + if onion_url and onion_url.strip(): + ou = onion_url.strip() + if ou.endswith(".onion") and not ( + ou.startswith("http://") or ou.startswith("https://") + ): + ou = f"http://{ou}" + endpoint_urls.append(ou) + + if not endpoint_urls: + logger.warning( + "No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish." + ) + return + + # Build metadata + metadata = { + "name": provider_name, + "about": provider_about, + } + + # Create the candidate event that we would publish + version_str = get_app_version() + candidate_event = create_nip91_event( + private_key_hex=private_key_hex, + provider_id=provider_id, + endpoint_urls=endpoint_urls, + mint_urls=mint_urls, + version=version_str, + metadata=metadata, + ) + + # Backoff configuration and state + backoff_base = float(os.getenv("NIP91_BACKOFF_BASE_SECONDS", "5")) + backoff_max = float(os.getenv("NIP91_BACKOFF_MAX_SECONDS", "900")) + backoff_jitter_ratio = float(os.getenv("NIP91_BACKOFF_JITTER_RATIO", "0.2")) + relay_next_allowed: dict[str, float] = {} + relay_current_delay: dict[str, float] = {} + + def _should_skip(relay: str) -> bool: + return time.time() < relay_next_allowed.get(relay, 0.0) + + def _register_success(relay: str) -> None: + relay_current_delay[relay] = 0.0 + relay_next_allowed[relay] = time.time() + + def _register_failure(relay: str) -> None: + previous = relay_current_delay.get(relay, 0.0) + delay = backoff_base if previous <= 0.0 else min(backoff_max, previous * 2.0) + jitter = delay * backoff_jitter_ratio * (2.0 * random.random() - 1.0) + scheduled = time.time() + max(0.0, delay + jitter) + relay_current_delay[relay] = delay + relay_next_allowed[relay] = scheduled + logger.debug( + f"Backoff: {relay} delay={delay:.1f}s jitter={jitter:.1f}s next={int(scheduled)}" + ) + + # Fetch existing events for this provider_id + existing_events: list[dict[str, Any]] = [] + for relay_url in relay_urls: + if _should_skip(relay_url): + logger.debug(f"Skipping {relay_url} due to backoff") + continue + events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id) + if ok: + _register_success(relay_url) + existing_events.extend(events) + else: + _register_failure(relay_url) + + # Decide whether to publish: publish if none exist or any differ from candidate + found_any = len(existing_events) > 0 + all_match = found_any and all( + events_semantically_equal(ev, candidate_event) for ev in existing_events + ) + + if not all_match: + logger.debug( + "No matching NIP-91 announcement found or differences detected; publishing update" + ) + success_count = 0 + for relay_url in relay_urls: + if _should_skip(relay_url): + logger.debug(f"Skipping publish to {relay_url} due to backoff") + continue + if await publish_to_relay(relay_url, candidate_event): + _register_success(relay_url) + success_count += 1 + else: + _register_failure(relay_url) + logger.info( + f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays" + ) + else: + logger.debug( + "Matching NIP-91 announcement already present; skipping publish on startup" + ) + + # Re-announce periodically (every 24 hours) + announcement_interval = int( + os.getenv("NIP91_ANNOUNCEMENT_INTERVAL", str(24 * 60 * 60)) + ) + + while True: + try: + await asyncio.sleep(announcement_interval) + + # Build fresh candidate event for comparison + version_str = get_app_version() + candidate_event = create_nip91_event( + private_key_hex=private_key_hex, + provider_id=provider_id, + endpoint_urls=endpoint_urls, + mint_urls=mint_urls, + version=version_str, + metadata=metadata, + ) + + # Fetch existing events for this provider_id + existing_events = [] + for relay_url in relay_urls: + if _should_skip(relay_url): + logger.debug(f"Skipping {relay_url} due to backoff") + continue + events, ok = await query_nip91_events( + relay_url, public_key_hex, provider_id + ) + if ok: + _register_success(relay_url) + existing_events.extend(events) + else: + _register_failure(relay_url) + + found_any = len(existing_events) > 0 + all_match = found_any and all( + events_semantically_equal(ev, candidate_event) for ev in existing_events + ) + + if all_match: + logger.debug( + "Matching NIP-91 announcement already present; skipping periodic re-announce" + ) + continue + + logger.debug( + f"Re-announcing provider due to differences or absence: {candidate_event['id']}" + ) + for relay_url in relay_urls: + if _should_skip(relay_url): + logger.debug(f"Skipping publish to {relay_url} due to backoff") + continue + ok = await publish_to_relay(relay_url, candidate_event) + if ok: + _register_success(relay_url) + else: + _register_failure(relay_url) + + except asyncio.CancelledError: + logger.info("NIP-91 announcement task cancelled") + break + except Exception as e: + logger.debug(f"Error in NIP-91 announcement loop: {type(e).__name__}") + # Continue running despite errors diff --git a/scripts/publish_provider.py b/scripts/publish_provider.py deleted file mode 100644 index 847c64ab..00000000 --- a/scripts/publish_provider.py +++ /dev/null @@ -1,268 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple Python function to publish one provider listing to a nostr relay -according to the RIP-02 specification. - -Based on: https://github.com/Routstr/protocol/blob/main/RIP-02.md -Event Kind: 31338 (Routstr Provider Announcements) -""" - -import asyncio -import hashlib -import json -import time -from typing import Any - -import secp256k1 -import websockets - - -def create_provider_announcement_event( - private_key_hex: str, - provider_name: str, - endpoint_url: str, - d_tag: str, - description: str | None = None, - contact: str | None = None, - pricing_url: str | None = None, - supported_models: list[str] | None = None, -) -> dict[str, Any]: - """ - Create a RIP-02 compliant provider announcement event. - - Args: - private_key_hex: 32-byte hex private key for signing - provider_name: Human readable name for the provider - endpoint_url: Base URL for the provider's API endpoint - d_tag: Unique identifier for this provider (required for addressable events) - description: Optional description of the provider - contact: Optional contact information - pricing_url: Optional URL to pricing information - supported_models: Optional list of supported model names - - Returns: - Complete signed nostr event ready for publishing - """ - # Convert hex private key to secp256k1 PrivateKey object - private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex)) - public_key = private_key.pubkey.serialize(compressed=True)[ - 1: - ] # Remove 0x02/0x03 prefix - - # Build required tags according to RIP-02 - tags = [ - ["d", d_tag], # Required for addressable events (kind 30000-39999) - ["endpoint", endpoint_url], - ["name", provider_name], - ] - - # Add optional tags if provided - if description: - tags.append(["description", description]) - if contact: - tags.append(["contact", contact]) - if pricing_url: - tags.append(["pricing", pricing_url]) - if supported_models: - for model in supported_models: - tags.append(["model", model]) - - # Create the event structure - created_at = int(time.time()) - event_data = [ - 0, # Reserved field - public_key.hex(), # Public key as hex - created_at, # Unix timestamp - 31338, # Kind for RIP-02 Provider Announcements - tags, # Tags array - "", # Content (empty for provider announcements) - ] - - # Serialize event data for hashing - event_json = json.dumps(event_data, separators=(",", ":"), ensure_ascii=False) - - # Calculate event ID (SHA256 hash) - event_id = hashlib.sha256(event_json.encode("utf-8")).hexdigest() - - # Sign the event ID - signature = private_key.ecdsa_sign(bytes.fromhex(event_id), raw=True) - signature_der = private_key.ecdsa_serialize(signature) - - # Create the final event - event = { - "id": event_id, - "pubkey": public_key.hex(), - "created_at": created_at, - "kind": 31338, - "tags": tags, - "content": "", - "sig": signature_der.hex(), - } - - return event - - -async def publish_provider_to_relay( - relay_url: str, event: dict[str, Any], timeout: int = 30 -) -> bool: - """ - Publish a provider announcement event to a nostr relay. - - Args: - relay_url: WebSocket URL of the nostr relay (e.g., "wss://relay.damus.io") - event: Complete signed nostr event to publish - timeout: Connection timeout in seconds - - Returns: - True if successfully published, False otherwise - """ - try: - async with websockets.connect(relay_url, timeout=timeout) as websocket: - # Send EVENT message - event_message = json.dumps(["EVENT", event]) - await websocket.send(event_message) - print(f"Published event {event['id']} to {relay_url}") - - # Wait for OK response - try: - response = await asyncio.wait_for(websocket.recv(), timeout=5) - data = json.loads(response) - - if data[0] == "OK" and data[1] == event["id"]: - if data[2]: # True means accepted - print( - f"✅ Event accepted by relay: {data[3] if len(data) > 3 else ''}" - ) - return True - else: - print( - f"❌ Event rejected by relay: {data[3] if len(data) > 3 else ''}" - ) - return False - elif data[0] == "NOTICE": - print(f"📢 Relay notice: {data[1]}") - return False - else: - print(f"🤔 Unexpected response: {data}") - return False - - except asyncio.TimeoutError: - print("⏰ No response from relay within timeout") - return False - - except Exception as e: - print(f"💥 Failed to publish to {relay_url}: {e}") - return False - - -async def publish_provider_listing( - private_key_hex: str, - provider_name: str, - endpoint_url: str, - d_tag: str, - relay_urls: list[str] | None = None, - description: str | None = None, - contact: str | None = None, - pricing_url: str | None = None, - supported_models: list[str] | None = None, -) -> dict[str, bool]: - """ - Complete function to create and publish a provider listing to nostr relays. - - Args: - private_key_hex: 32-byte hex private key for signing - provider_name: Human readable name for the provider - endpoint_url: Base URL for the provider's API endpoint - d_tag: Unique identifier for this provider - relay_urls: List of relay URLs to publish to (uses defaults if None) - description: Optional description of the provider - contact: Optional contact information - pricing_url: Optional URL to pricing information - supported_models: Optional list of supported model names - - Returns: - Dictionary mapping relay URLs to success status - """ - # Use default relays if none provided - if relay_urls is None: - relay_urls = [ - "wss://relay.nostr.band", - "wss://relay.damus.io", - "wss://relay.routstr.com", - ] - - # Create the provider announcement event - event = create_provider_announcement_event( - private_key_hex=private_key_hex, - provider_name=provider_name, - endpoint_url=endpoint_url, - d_tag=d_tag, - description=description, - contact=contact, - pricing_url=pricing_url, - supported_models=supported_models, - ) - - print(f"📝 Created provider announcement event: {event['id']}") - print(f"🔑 Public key: {event['pubkey']}") - print(f"🏷️ Provider: {provider_name}") - print(f"🌐 Endpoint: {endpoint_url}") - print() - - # Publish to all specified relays - results = {} - tasks = [] - - for relay_url in relay_urls: - task = publish_provider_to_relay(relay_url, event) - tasks.append((relay_url, task)) - - # Execute all publishing tasks concurrently - for relay_url, task in tasks: - try: - success = await task - results[relay_url] = success - except Exception as e: - print(f"💥 Failed to publish to {relay_url}: {e}") - results[relay_url] = False - - return results - - -# Example usage -async def main() -> None: - """Example of how to use the provider publishing function.""" - - # Example private key (DO NOT use this in production!) - private_key = "3185a47e3802f956ca207b46c8d6b8b5c5dbad53a5ca29816050e9b66badc33c" - - # Example provider information - provider_name = "My AI Provider" - endpoint_url = "https://api.myaiprovider.com" - d_tag = "my-ai-provider-v1" # Unique identifier - description = "High-quality AI models with competitive pricing" - contact = "admin@myaiprovider.com" - pricing_url = "https://myaiprovider.com/pricing" - supported_models = ["gpt-4o", "claude-3-sonnet", "llama-3.1-70b"] - - # Publish to relays - results = await publish_provider_listing( - private_key_hex=private_key, - provider_name=provider_name, - endpoint_url=endpoint_url, - d_tag=d_tag, - description=description, - contact=contact, - pricing_url=pricing_url, - supported_models=supported_models, - ) - - # Print results - print("\n📊 Publishing Results:") - for relay_url, success in results.items(): - status = "✅ Success" if success else "❌ Failed" - print(f" {relay_url}: {status}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/integration/test_provider_management.py b/tests/integration/test_provider_management.py index 1ea5a321..788ad768 100644 --- a/tests/integration/test_provider_management.py +++ b/tests/integration/test_provider_management.py @@ -9,9 +9,16 @@ from unittest.mock import patch import pytest from httpx import AsyncClient +from routstr.discovery import _PROVIDERS_CACHE + from .utils import PerformanceValidator, ResponseValidator +@pytest.fixture(autouse=True) +def _clear_providers_cache() -> None: + _PROVIDERS_CACHE.clear() + + @pytest.mark.integration @pytest.mark.asyncio async def test_providers_endpoint_default_response( @@ -26,13 +33,25 @@ async def test_providers_endpoint_default_response( mock_events: list[dict[str, Any]] = [ { "id": "event1", - "content": "Check out this provider: http://provider1.onion", + "pubkey": "test_pubkey1", + "kind": 38421, # NIP-91 event kind "created_at": 1234567890, + "content": '{"name": "Provider 1", "about": "Test provider 1"}', + "tags": [ + ["d", "provider1"], + ["u", "http://provider1.onion"], + ], }, { "id": "event2", - "content": "Another provider at http://provider2.onion is good", + "pubkey": "test_pubkey2", + "kind": 38421, # NIP-91 event kind "created_at": 1234567891, + "content": '{"name": "Provider 2", "about": "Test provider 2"}', + "tags": [ + ["d", "provider2"], + ["u", "http://provider2.onion"], + ], }, ] @@ -60,10 +79,11 @@ async def test_providers_endpoint_default_response( assert "providers" in data assert isinstance(data["providers"], list) - # In default format, should return list of provider URLs (strings) + # In default format, should return list of provider objects for provider in data["providers"]: - assert isinstance(provider, str) - assert provider.endswith(".onion") + assert isinstance(provider, dict) + assert "endpoint_url" in provider + assert provider["endpoint_url"].endswith(".onion") # Verify no database state changes diff = await db_snapshot.diff() @@ -86,8 +106,14 @@ async def test_providers_endpoint_with_include_json( mock_events: list[dict[str, Any]] = [ { "id": "event1", - "content": "Provider info: http://test-provider.onion", + "pubkey": "test_pubkey", + "kind": 38421, # NIP-91 event kind "created_at": 1234567890, + "content": '{"name": "Test Provider", "about": "A test provider"}', + "tags": [ + ["d", "test-provider"], + ["u", "http://test-provider.onion"], + ], } ] @@ -117,15 +143,20 @@ async def test_providers_endpoint_with_include_json( assert "providers" in data assert isinstance(data["providers"], list) - # With include_json=true, should return list of dictionaries - for provider in data["providers"]: - assert isinstance(provider, dict) - # Each provider should be in format {url: json_data} - assert len(provider) == 1 - url = list(provider.keys())[0] - json_data = provider[url] - assert url.endswith(".onion") - assert isinstance(json_data, dict) + # With include_json=true, should return list of dictionaries with provider and health info + for provider_data in data["providers"]: + assert isinstance(provider_data, dict) + # Each provider should have 'provider' and 'health' keys + assert "provider" in provider_data + assert "health" in provider_data + + provider_info = provider_data["provider"] + assert "endpoint_url" in provider_info + assert provider_info["endpoint_url"].endswith(".onion") + + health_info = provider_data["health"] + assert isinstance(health_info, dict) + assert "status_code" in health_info # Verify no database state changes diff = await db_snapshot.diff() @@ -141,20 +172,18 @@ async def test_providers_data_structure_validation( ) -> None: """Test provider data structure contains expected fields""" - # Mock RIP-02 provider announcement event + # Mock NIP-91 provider announcement event mock_events: list[dict[str, Any]] = [ { "id": "event1", "pubkey": "test_pubkey", "created_at": 1234567890, - "content": "Comprehensive provider announcement", + "kind": 38421, # NIP-91 event kind + "content": '{"name": "Comprehensive Provider", "about": "A comprehensive AI provider"}', "tags": [ ["d", "provider-123"], - ["endpoint", "https://api.provider.example/v1"], - ["name", "Comprehensive Provider"], - ["description", "A comprehensive AI provider"], - ["model", "gpt-3.5-turbo"], - ["model", "gpt-4"], + ["u", "https://api.provider.example/v1"], + ["models", "gpt-3.5-turbo", "gpt-4"], ], } ] @@ -190,19 +219,13 @@ async def test_providers_data_structure_validation( assert "health" in provider_data provider_info = provider_data["provider"] - # Expected fields from RIP-02 parser - expected_fields = ["id", "name", "endpoint_url", "supported_models"] + # health_info = provider_data["health"] + + # Expected fields from NIP-91 parser (supported_models removed) + expected_fields = ["id", "name", "endpoint_url"] for field in expected_fields: assert field in provider_info - # Validate models structure if present - if "supported_models" in provider_info: - models = provider_info["supported_models"] - assert isinstance(models, list) - # Should have the models from the mocked event - assert "gpt-3.5-turbo" in models - assert "gpt-4" in models - @pytest.mark.integration @pytest.mark.asyncio @@ -211,8 +234,17 @@ async def test_providers_endpoint_no_providers_found( ) -> None: """Test providers endpoint when no providers are found""" - # Mock empty events (no providers mentioned) - mock_events: list[dict[str, Any]] = [] + # Force empty discovery by returning events that are filtered out + mock_events: list[dict[str, Any]] = [ + { + "id": "localhost-event", + "pubkey": "ignored_pubkey", + "kind": 38421, + "created_at": 1234567899, + "content": '{"name": "Local"}', + "tags": [["d", "local"], ["u", "http://localhost:8000"]], + } + ] with patch( "routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events @@ -239,23 +271,23 @@ async def test_providers_endpoint_offline_providers( { "id": "event1", "pubkey": "healthy_provider_pubkey", + "kind": 38421, # NIP-91 event kind "created_at": 1234567890, - "content": "Healthy provider announcement", + "content": '{"name": "Healthy Provider", "about": "Healthy provider announcement"}', "tags": [ ["d", "healthy-provider"], - ["endpoint", "http://healthy-provider.onion"], - ["name", "Healthy Provider"], + ["u", "http://healthy-provider.onion"], ], }, { "id": "event2", "pubkey": "offline_provider_pubkey", + "kind": 38421, # NIP-91 event kind "created_at": 1234567891, - "content": "Offline provider announcement", + "content": '{"name": "Offline Provider", "about": "Offline provider announcement"}', "tags": [ ["d", "offline-provider"], - ["endpoint", "http://offline-provider.onion"], - ["name", "Offline Provider"], + ["u", "http://offline-provider.onion"], ], }, ] @@ -323,23 +355,23 @@ async def test_providers_endpoint_duplicate_urls( { "id": "event1", "pubkey": "provider_pubkey", + "kind": 38421, # NIP-91 event kind "created_at": 1234567890, - "content": "Provider announcement", + "content": '{"name": "Provider", "about": "Provider announcement"}', "tags": [ ["d", "provider-1"], - ["endpoint", "http://provider.onion"], - ["name", "Provider"], + ["u", "http://provider.onion"], ], }, { "id": "event2", "pubkey": "other_provider_pubkey", + "kind": 38421, # NIP-91 event kind "created_at": 1234567892, - "content": "Different provider announcement", + "content": '{"name": "Other Provider", "about": "Different provider announcement"}', "tags": [ ["d", "other-provider"], - ["endpoint", "http://other-provider.onion"], - ["name", "Other Provider"], + ["u", "http://other-provider.onion"], ], }, ] @@ -432,11 +464,10 @@ async def test_providers_endpoint_malformed_urls( assert response.status_code == 200 data = response.json() - # Should only extract valid onion URLs - providers = data["providers"] - for provider in providers: - assert provider.startswith("http://") or provider.startswith("https://") - assert provider.endswith(".onion") + # With NIP-91-only parsing, events without required tags are ignored + assert "providers" in data + assert isinstance(data["providers"], list) + assert len(data["providers"]) == 0 @pytest.mark.integration @@ -575,8 +606,14 @@ async def test_providers_endpoint_parameter_validation( mock_events: list[dict[str, Any]] = [ { "id": "event1", - "content": "Provider: http://param-test-provider.onion", + "pubkey": "param_pubkey", + "kind": 38421, "created_at": 1234567890, + "content": '{"name": "Param Test Provider"}', + "tags": [ + ["d", "param-test-provider"], + ["u", "http://param-test-provider.onion"], + ], } ] @@ -604,13 +641,14 @@ async def test_providers_endpoint_parameter_validation( if len(providers) > 0: if expected_json_format: - # Should be list of dictionaries + # Should be list of {provider, health} dictionaries + for item in providers: + assert isinstance(item, dict) + assert "provider" in item and "health" in item + else: + # Should be list of provider objects for provider in providers: assert isinstance(provider, dict) - else: - # Should be list of strings - for provider in providers: - assert isinstance(provider, str) @pytest.mark.integration diff --git a/uv.lock b/uv.lock index c6f2bacd..0bd3ee67 100644 --- a/uv.lock +++ b/uv.lock @@ -1361,6 +1361,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/73/d6b999782ae22f16971cc05378b3b33f6a89ede3b9619e8366aa23484bca/mypy_protobuf-3.6.0-py3-none-any.whl", hash = "sha256:56176e4d569070e7350ea620262478b49b7efceba4103d468448f1d21492fd6c", size = 16434 }, ] +[[package]] +name = "nostr" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "cryptography" }, + { name = "pycparser" }, + { name = "secp256k1" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/e1/1e24d8d2d75d28871f5b7d03304eda8250121ab665180872b9ba4ff70cc9/nostr-0.0.2.tar.gz", hash = "sha256:5c0c472f69764ae57870710d6b3bfe584df3ccdb0e2e3cd3f302f6b848124d24", size = 17189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ef/468ed56f0bea8e8979acf561273f8af2e7c9b3d8dc37bf80e81df08372a3/nostr-0.0.2-py3-none-any.whl", hash = "sha256:3d17d22dbd3aecf1ddf8cc72e330f14702e159fb0f43320d1ac88142db96aaba", size = 15397 }, +] + [[package]] name = "openai" version = "1.98.0" @@ -1767,7 +1783,7 @@ wheels = [ [[package]] name = "routstr" -version = "0.1.1b" +version = "0.1.1b0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -1777,9 +1793,11 @@ dependencies = [ { name = "greenlet" }, { name = "httpx", extra = ["socks"] }, { name = "marshmallow" }, + { name = "nostr" }, { name = "python-json-logger" }, { name = "secp256k1" }, { name = "sqlmodel" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -1806,9 +1824,11 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.2.1" }, { name = "httpx", extras = ["socks"], specifier = ">=0.25.2" }, { name = "marshmallow", specifier = ">=3.13,<4.0" }, + { name = "nostr", specifier = ">=0.0.2" }, { name = "python-json-logger", specifier = ">=2.0.0" }, { name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" }, { name = "sqlmodel", specifier = ">=0.0.24" }, + { name = "websockets", specifier = ">=12.0" }, ] [package.metadata.requires-dev]