feat(relay): advertise admin API origin via NIP-11 admin_api field

Desktop had no way to discover the admin API endpoint and forced users to
type its URL by hand. Advertise the canonical admin origin
(scheme://host[:port], no path) in the NIP-11 relay-information document
under an optional admin_api field, present iff the admin surface is
configured (config.admin.is_some()).

Extract the loopback scheme rule into a shared scheme_for_host helper so the
advertised origin and the NIP-98 u-tag the relay verifies can never use
different schemes; a test enforces the invariant. The helper now parses IPv6
authorities (bracketed [::1]:3000 and bare ::1) correctly instead of letting
a colon-split mangle them.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-12 23:20:29 -04:00
co-authored by Will Pfleger
parent 5505e608b9
commit bcfa86046f
4 changed files with 185 additions and 17 deletions
+86 -9
View File
@@ -88,17 +88,48 @@ pub(crate) fn is_admin_host(state: &AppState, headers: &HeaderMap) -> bool {
.is_some_and(|host| host == config.host)
}
/// Derive the canonical URL for a NIP-98 `u`-tag check.
/// Scheme for an admin authority: `http://` for loopback hosts (localhost,
/// `::1`, 127.x), else `https://` — matching local dev via the Justfile.
///
/// Scheme: `https://` unless the host is a loopback address (localhost or
/// 127.x.x.x), in which case `http://` is used — matching local dev via
/// the Justfile.
fn canonical_url(host: &str, path: &str) -> String {
let host_part = host.split(':').next().unwrap_or(host);
/// Shared by [`canonical_url`] (NIP-98 `u`-tag verification) and
/// [`admin_api_origin`] (NIP-11 advertisement) so the origin the relay
/// advertises and the origin it verifies against can never use different
/// schemes.
fn scheme_for_host(host: &str) -> &'static str {
// Strip any `:port` to get the bare host. IPv6 literals carry their own
// colons, so a plain `split(':')` would mangle them: bracketed authorities
// (`[::1]:3000`) take the text inside the brackets, and an unbracketed
// multi-colon host is a bare IPv6 literal with no port.
let host_part = if let Some(rest) = host.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else if host.matches(':').count() > 1 {
host
} else {
host.split(':').next().unwrap_or(host)
};
let is_loopback =
host_part == "localhost" || host_part == "::1" || host_part.starts_with("127.");
let scheme = if is_loopback { "http" } else { "https" };
format!("{scheme}://{host}{path}")
if is_loopback {
"http"
} else {
"https"
}
}
/// Derive the canonical URL for a NIP-98 `u`-tag check.
fn canonical_url(host: &str, path: &str) -> String {
format!("{}://{host}{path}", scheme_for_host(host))
}
/// Canonical admin API origin (`scheme://host[:port]`, no path) advertised in
/// the NIP-11 document so desktop can auto-discover the admin surface instead
/// of requiring manual URL entry.
///
/// Scheme follows the same loopback rule as [`canonical_url`], so a client that
/// discovers this origin signs NIP-98 `u` tags against the exact scheme the
/// relay verifies.
pub(crate) fn admin_api_origin(host: &str) -> String {
format!("{}://{host}", scheme_for_host(host))
}
/// Whether a request method typically carries a body.
@@ -415,7 +446,8 @@ fn origin_matches_host(origin: &str, host: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
bearer_credential, canonical_url, method_has_body, nostr_credential, origin_matches_host,
admin_api_origin, bearer_credential, canonical_url, method_has_body, nostr_credential,
origin_matches_host,
};
#[test]
@@ -485,6 +517,51 @@ mod tests {
assert_eq!(canonical_url("127.0.0.1", "/path"), "http://127.0.0.1/path");
}
#[test]
fn admin_api_origin_uses_https_for_non_loopback_hosts() {
assert_eq!(
admin_api_origin("admin.example.com"),
"https://admin.example.com"
);
assert_eq!(
admin_api_origin("admin.example.com:8443"),
"https://admin.example.com:8443"
);
}
#[test]
fn admin_api_origin_uses_http_for_loopback_hosts() {
assert_eq!(admin_api_origin("localhost:3000"), "http://localhost:3000");
assert_eq!(admin_api_origin("127.0.0.1:3000"), "http://127.0.0.1:3000");
assert_eq!(admin_api_origin("::1"), "http://::1");
// Bracketed IPv6 authority with a port (the RFC 3986 Host-header form).
assert_eq!(admin_api_origin("[::1]:3000"), "http://[::1]:3000");
}
/// The advertised origin and the verified `u`-tag URL must agree on scheme
/// for every host, or a discovered origin would sign against a scheme the
/// relay rejects.
#[test]
fn admin_api_origin_scheme_matches_canonical_url_scheme() {
for host in [
"admin.example.com",
"admin.example.com:8443",
"localhost:3000",
"127.0.0.1:3000",
"::1",
"[::1]:3000",
] {
let advertised = admin_api_origin(host);
let verified = canonical_url(host, "/api/admin/v1/reports");
let advertised_scheme = advertised.split("://").next().expect("scheme");
let verified_scheme = verified.split("://").next().expect("scheme");
assert_eq!(
advertised_scheme, verified_scheme,
"advertised and verified schemes must match for host {host}"
);
}
}
#[test]
fn body_bearing_methods_are_correctly_identified() {
for m in [
+5
View File
@@ -28,6 +28,11 @@ pub(crate) fn is_admin_host(state: &crate::state::AppState, headers: &HeaderMap)
auth::is_admin_host(state, headers)
}
/// Canonical admin API origin advertised in the NIP-11 document (see
/// [`auth::admin_api_origin`]). Re-exported so the NIP-11 builder can derive
/// the advertised origin without reaching into the private `auth` module.
pub(crate) use auth::admin_api_origin;
/// Build the deployment-admin routes.
///
/// Read routes are available in all auth modes.
+1
View File
@@ -1446,6 +1446,7 @@ mod tests {
false,
crate::config::DEFAULT_MAX_FRAME_BYTES,
None,
None,
)
.limitation
.expect("limitation")
+93 -8
View File
@@ -52,6 +52,14 @@ pub struct RelayInfo {
/// Public WebSocket URL of the dedicated NIP-AB device-pairing relay.
#[serde(skip_serializing_if = "Option::is_none")]
pub pairing_relay_url: Option<String>,
/// Canonical origin (`scheme://host[:port]`, no path) of the deployment
/// admin API, advertised only when the admin surface is configured
/// (`config.admin.is_some()`). Lets desktop auto-discover the admin
/// console instead of requiring manual URL entry. Scheme follows the same
/// loopback rule as NIP-98 `u`-tag verification (see
/// [`crate::api::admin::admin_api_origin`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_api: Option<String>,
/// Relay's own signing pubkey (NIP-11 `self` field, NIP-43).
#[serde(rename = "self", skip_serializing_if = "Option::is_none")]
pub relay_self: Option<String>,
@@ -138,12 +146,17 @@ impl RelayInfo {
/// gates on NIP-43 events — i.e. has a stable key AND enforces
/// membership. NIP-43 events are verified against `self`, so it is a
/// programmer error to advertise NIP-43 without a `relay_self`.
///
/// `admin_api` is the canonical admin API origin, advertised only when the
/// admin surface is configured; a per-deployment scalar derived from
/// config by the caller (see [`nip11_document`]).
pub fn build(
relay_self: Option<&str>,
icon: Option<&str>,
advertise_nip43: bool,
max_message_length: usize,
pairing_relay_url: Option<&str>,
admin_api: Option<&str>,
) -> Self {
debug_assert!(
!advertise_nip43 || relay_self.is_some(),
@@ -168,6 +181,7 @@ impl RelayInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
limitation: Some(relay_limitation(max_message_length)),
pairing_relay_url: pairing_relay_url.map(str::to_string),
admin_api: admin_api.map(str::to_string),
relay_self: relay_self.map(|s| s.to_string()),
}
}
@@ -240,12 +254,14 @@ fn push_descriptor(
pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo {
let (relay_self, advertise_nip43) = nip11_facts(state);
let icon = workspace_icon_for_host(state, raw_host).await;
let admin_api = admin_api_advertisement(state.config.admin.as_ref());
let mut info = RelayInfo::build(
relay_self.as_deref(),
icon.as_deref(),
advertise_nip43,
state.config.max_frame_bytes,
state.config.pairing_relay_url.as_deref(),
admin_api.as_deref(),
);
let tenant_host = if state.config.push_gateway_delivery_url.is_some() {
crate::tenant::bind_community(&state.db, raw_host)
@@ -310,6 +326,18 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bo
(relay_self, advertise_nip43)
}
/// Derives the NIP-11 `admin_api` advertisement: the canonical admin API
/// origin, present iff the admin surface is configured
/// (`config.admin.is_some()`), absent otherwise — never an empty string.
///
/// The origin is derived purely from the configured admin host by
/// [`crate::api::admin::admin_api_origin`] (loopback → `http`, else `https`),
/// so it is a per-deployment scalar with no unscoped DB/tenant input, keeping
/// [`RelayInfo::build`] within its static-input contract.
fn admin_api_advertisement(admin: Option<&crate::config::AdminConfig>) -> Option<String> {
admin.map(|admin| crate::api::admin::admin_api_origin(&admin.host))
}
/// Multi-tenant conformance static-input fence (surface row "NIP-11 relay info
/// and relay `self`").
///
@@ -337,6 +365,7 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
bool,
usize,
Option<&str>,
Option<&str>,
) -> RelayInfo = RelayInfo::build;
#[cfg(test)]
@@ -391,7 +420,7 @@ mod tests {
#[test]
fn build_advertises_buzz_repository_url() {
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert_eq!(info.software, "https://github.com/block/buzz");
}
@@ -403,6 +432,7 @@ mod tests {
false,
DEFAULT_MAX_FRAME_BYTES,
Some("wss://pairing.buzz.xyz"),
None,
);
let json = serde_json::to_value(&info).expect("serialize");
assert_eq!(
@@ -411,7 +441,7 @@ mod tests {
Some("wss://pairing.buzz.xyz")
);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
let json = serde_json::to_value(&info).expect("serialize");
assert!(json.get("pairing_relay_url").is_none());
}
@@ -427,6 +457,7 @@ mod tests {
false,
DEFAULT_MAX_FRAME_BYTES,
None,
None,
);
assert_eq!(
info.icon.as_deref(),
@@ -439,7 +470,7 @@ mod tests {
);
for icon in [None, Some("")] {
let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert!(info.icon.is_none());
let json = serde_json::to_value(&info).expect("serialize");
assert!(
@@ -459,7 +490,7 @@ mod tests {
#[test]
fn max_message_length_uses_configured_frame_limit() {
let info = RelayInfo::build(None, None, false, 262_144, None);
let info = RelayInfo::build(None, None, false, 262_144, None, None);
let limitation = info.limitation.expect("limitation");
assert_eq!(limitation.max_message_length, Some(262_144));
}
@@ -490,7 +521,7 @@ mod tests {
/// Open relay, ephemeral key — both `self` and NIP-43 are absent.
#[test]
fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert!(info.relay_self.is_none());
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
@@ -503,7 +534,7 @@ mod tests {
#[test]
fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert_eq!(info.relay_self.as_deref(), Some(pk));
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
@@ -512,7 +543,7 @@ mod tests {
#[test]
fn build_membership_relay_advertises_self_and_nip43() {
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None);
let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None, None);
assert_eq!(info.relay_self.as_deref(), Some(pk));
assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
}
@@ -523,6 +554,60 @@ mod tests {
#[test]
#[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
fn build_nip43_without_self_panics_in_debug() {
let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None);
let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None, None);
}
fn admin_config(host: &str) -> crate::config::AdminConfig {
crate::config::AdminConfig {
host: host.to_string(),
auth: crate::config::AdminAuth::Nip98,
web_dir: None,
}
}
/// The admin surface is unconfigured: `admin_api` must be absent, and the
/// serialized document must omit the field entirely (not `null`).
#[test]
fn admin_api_absent_when_admin_surface_not_configured() {
assert_eq!(admin_api_advertisement(None), None);
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None);
assert!(info.admin_api.is_none());
let json = serde_json::to_value(&info).expect("serialize");
assert!(
json.get("admin_api").is_none(),
"unconfigured admin surface must omit the `admin_api` field"
);
}
/// Loopback admin host → advertised as an `http://` origin (matches the
/// NIP-98 canonicalizer's loopback rule so a discovered origin signs
/// against the scheme the relay verifies).
#[test]
fn admin_api_advertised_as_http_for_loopback_host() {
let advertised = admin_api_advertisement(Some(&admin_config("127.0.0.1:3000")));
assert_eq!(advertised.as_deref(), Some("http://127.0.0.1:3000"));
let info = RelayInfo::build(
None,
None,
false,
DEFAULT_MAX_FRAME_BYTES,
None,
advertised.as_deref(),
);
let json = serde_json::to_value(&info).expect("serialize");
assert_eq!(
json.get("admin_api").and_then(|v| v.as_str()),
Some("http://127.0.0.1:3000")
);
}
/// Non-loopback admin host → advertised as an `https://` origin, with no
/// path/query/fragment (a bare origin).
#[test]
fn admin_api_advertised_as_https_for_non_loopback_host() {
let advertised = admin_api_advertisement(Some(&admin_config("admin.example.com")));
assert_eq!(advertised.as_deref(), Some("https://admin.example.com"));
}
}