feat(relay): advertise the relay via /capabilities + NIP-05 relay hints

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.
This commit is contained in:
jwinterm
2026-07-02 07:00:02 -04:00
parent 63d2c75194
commit f1872c781c
4 changed files with 110 additions and 4 deletions
+51
View File
@@ -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": {
+39
View File
@@ -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<u16>,
}
/// 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<MessagingCapability>,
}
/// 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,
+19 -4
View File
@@ -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<String> {
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
+1
View File
@@ -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,