From f1872c781c150ae4bc789156d39c5eaeb5d306f8 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 2 Jul 2026 07:00:02 -0400 Subject: [PATCH] feat(relay): advertise the relay via /capabilities + NIP-05 relay hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So clients can discover + connect to the relay (and self-adapt when it's off): - /capabilities: features.nostr_relay + a messaging{ relay_url, write_policy, inbound_pow_bits, supported_nips } object, with the same config-presence downgrade as chains/nostr_identity (advertised only when enabled AND a URL is configured — never advertise a relay clients can't reach). OpenAPI regenerated. - nip05.rs: /.well-known/nostr.json relay hints are now config-driven — this instance's own relay (the user's DM inbox) first when enabled, then the public interop relays (damus/nos.lol) so cross-wallet delivery still works. Replaces the hardcoded-only default. --- openapi.json | 51 +++++++++++++++++++++++++++++++++++++++++ src/api/capabilities.rs | 39 +++++++++++++++++++++++++++++++ src/api/nip05.rs | 23 +++++++++++++++---- src/api/openapi.rs | 1 + 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/openapi.json b/openapi.json index 9ebf146..33be2f5 100644 --- a/openapi.json +++ b/openapi.json @@ -1976,6 +1976,17 @@ "features": { "$ref": "#/components/schemas/FeatureCapabilities" }, + "messaging": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/MessagingCapability", + "description": "First-party Nostr relay details; present only when `features.nostr_relay`." + } + ] + }, "registration": { "$ref": "#/components/schemas/RegistrationCapability", "description": "Registration gates for a new wallet on this instance." @@ -2253,6 +2264,7 @@ "grin_relay", "prices", "nostr_identity", + "nostr_relay", "tips" ], "properties": { @@ -2264,6 +2276,10 @@ "type": "boolean", "description": "Nostr-native identity (NIP-98 login/link, NIP-05 directory)." }, + "nostr_relay": { + "type": "boolean", + "description": "First-party Nostr relay (encrypted DM inbox). See `messaging` for details." + }, "prices": { "type": "boolean", "description": "Fiat price feed." @@ -2741,6 +2757,41 @@ } } }, + "MessagingCapability": { + "type": "object", + "description": "First-party Nostr relay details (present only when `features.nostr_relay`).\nThe wallet connects here as its DM inbox (alongside the public interop\nrelays) and adapts its UI to the policy.", + "required": [ + "relay_url", + "write_policy", + "inbound_pow_bits", + "supported_nips" + ], + "properties": { + "inbound_pow_bits": { + "type": "integer", + "format": "int32", + "description": "NIP-13 PoW bits required on cross-ecosystem inbound (0 = off).", + "minimum": 0 + }, + "relay_url": { + "type": "string", + "description": "The ws(s):// relay URL clients connect to." + }, + "supported_nips": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "description": "NIPs the relay speaks (e.g. `[1, 17, 44, 59]`)." + }, + "write_policy": { + "type": "string", + "description": "Write policy: `inbox-outbox` | `author-allowlist` | `open`." + } + } + }, "MyUsernameResponse": { "type": "object", "properties": { diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index ade5210..95e94a5 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -45,10 +45,27 @@ pub struct FeatureCapabilities { pub prices: bool, /// Nostr-native identity (NIP-98 login/link, NIP-05 directory). pub nostr_identity: bool, + /// First-party Nostr relay (encrypted DM inbox). See `messaging` for details. + pub nostr_relay: bool, /// Tipping (parked). pub tips: bool, } +/// First-party Nostr relay details (present only when `features.nostr_relay`). +/// The wallet connects here as its DM inbox (alongside the public interop +/// relays) and adapts its UI to the policy. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct MessagingCapability { + /// The ws(s):// relay URL clients connect to. + pub relay_url: String, + /// Write policy: `inbox-outbox` | `author-allowlist` | `open`. + pub write_policy: String, + /// NIP-13 PoW bits required on cross-ecosystem inbound (0 = off). + pub inbound_pow_bits: u8, + /// NIPs the relay speaks (e.g. `[1, 17, 44, 59]`). + pub supported_nips: Vec, +} + /// This instance's wallet-restore (import) policy. The wallet uses it to adapt /// its import UX — hide/grey the restore-height field under `create-only`, warn /// when a chosen date exceeds the bound. `max_depth_days` is present only for @@ -97,6 +114,9 @@ pub struct CapabilitiesResponse { pub restore: RestoreCapability, /// Registration gates for a new wallet on this instance. pub registration: RegistrationCapability, + /// First-party Nostr relay details; present only when `features.nostr_relay`. + #[serde(skip_serializing_if = "Option::is_none")] + pub messaging: Option, } /// Whether an enabled chain can actually be served (its infra secret/URL is @@ -153,6 +173,9 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { // Nostr identity needs the canonical PUBLIC_API_URL. nostr_identity: config.features.nostr_identity && config.identity.public_api_url.is_some(), + // Relay advertised only when enabled AND a URL is configured (config + // presence downgrade — never advertise a relay clients can't reach). + nostr_relay: relay_advertised(config), tips: config.features.tips, }, restore: RestoreCapability { @@ -180,9 +203,25 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { .require_payment .then(|| config.registration.payment.currency.clone()), }, + messaging: relay_advertised(config).then(|| { + let r = &config.messaging.relay; + MessagingCapability { + relay_url: r.advertised_url.clone(), + write_policy: r.write_policy.clone(), + inbound_pow_bits: r.inbound_pow_bits, + supported_nips: crate::infra::relay::SUPPORTED_NIPS.to_vec(), + } + }), } } +/// Whether the relay is enabled AND reachable (a URL is configured). Mirrors the +/// chain/nostr-identity secret-presence downgrade: never advertise a relay the +/// client can't connect to. +fn relay_advertised(config: &Config) -> bool { + config.messaging.relay.enabled && !config.messaging.relay.advertised_url.trim().is_empty() +} + /// Describe this instance's enabled chains and features. #[utoipa::path( get, diff --git a/src/api/nip05.rs b/src/api/nip05.rs index 9c1ff72..5c1856a 100644 --- a/src/api/nip05.rs +++ b/src/api/nip05.rs @@ -108,10 +108,7 @@ pub async fn well_known_nostr( let name = raw.to_lowercase(); if let Some(user) = state.db.get_user_by_username(&name).await? { if let Some(pubkey) = user.nostr_pubkey { - relays.insert( - pubkey.clone(), - DEFAULT_RELAYS.iter().map(|r| r.to_string()).collect(), - ); + relays.insert(pubkey.clone(), relay_hints(&state.config)); names.insert(name, pubkey); } } @@ -120,6 +117,24 @@ pub async fn well_known_nostr( Ok(Json(WellKnownResponse { names, relays })) } +/// Relay hints advertised for a resolved pubkey: this instance's own relay (the +/// user's DM inbox) FIRST when enabled, then the public interop relays so +/// cross-wallet (e.g. Goblin) delivery still works before any per-user list. +/// Per-user lists (NIP-65 kind 10002 / NIP-17 inbox relays) remain future work. +fn relay_hints(config: &crate::config::Config) -> Vec { + let mut hints = Vec::new(); + let r = &config.messaging.relay; + if r.enabled && !r.advertised_url.trim().is_empty() { + hints.push(r.advertised_url.clone()); + } + for d in DEFAULT_RELAYS { + if !hints.iter().any(|h| h == d) { + hints.push((*d).to_string()); + } + } + hints +} + // ── router ───────────────────────────────────────────────────────────────────── /// NIP-05 route, mounted at the SERVER ROOT (NOT under `/api/v1`). NIP-05 diff --git a/src/api/openapi.rs b/src/api/openapi.rs index a9f8a72..78f0380 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -71,6 +71,7 @@ use utoipa::OpenApi; crate::api::capabilities::FeatureCapabilities, crate::api::capabilities::RestoreCapability, crate::api::capabilities::RegistrationCapability, + crate::api::capabilities::MessagingCapability, crate::api::prices::PricesResponse, crate::api::auth::AuthResponse, crate::api::auth::UserInfo,