commit cba8126bd69d38cd209799054ca721b8790a62e1
parent 08a48938b151fd7006f53cd46550bc82b958ef04
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 00:46:49 -0700
http: add relay info endpoint
Diffstat:
1 file changed, 199 insertions(+), 3 deletions(-)
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -7,9 +7,12 @@ use axum::{
routing::get,
};
use core::fmt;
-use http::StatusCode;
+use http::{HeaderMap, HeaderValue, StatusCode, header};
use serde::{Deserialize, Serialize};
+pub const TANGLE_SUPPORTED_NIPS: [u16; 8] = [1, 9, 11, 16, 33, 42, 50, 99];
+pub const TANGLE_RELAY_SOFTWARE: &str = "https://github.com/radrootslabs/tangle";
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiErrorCode {
InvalidRequest,
@@ -222,6 +225,51 @@ pub struct ReadinessChecksDocument {
pub repository: String,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RelayInfoDocument {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub id: Option<String>,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub contact: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub icon: Option<String>,
+ pub supported_nips: Vec<u16>,
+ pub software: String,
+ pub version: String,
+ pub limitation: RelayInfoLimitationDocument,
+}
+
+impl RelayInfoDocument {
+ pub fn tangle_default() -> Self {
+ Self {
+ id: None,
+ name: "tangle".to_owned(),
+ description: Some("SurrealDB-backed Nostr relay for NIP-99 marketplaces".to_owned()),
+ pubkey: None,
+ contact: None,
+ icon: None,
+ supported_nips: TANGLE_SUPPORTED_NIPS.to_vec(),
+ software: TANGLE_RELAY_SOFTWARE.to_owned(),
+ version: env!("CARGO_PKG_VERSION").to_owned(),
+ limitation: RelayInfoLimitationDocument {
+ payment_required: false,
+ restricted_writes: true,
+ },
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RelayInfoLimitationDocument {
+ pub payment_required: bool,
+ pub restricted_writes: bool,
+}
+
pub fn health_router(readiness: ReadinessState) -> Router {
Router::new()
.route("/healthz", get(healthz))
@@ -229,6 +277,12 @@ pub fn health_router(readiness: ReadinessState) -> Router {
.with_state(readiness)
}
+pub fn relay_info_router(document: RelayInfoDocument) -> Router {
+ Router::new()
+ .route("/", get(relay_info))
+ .with_state(document)
+}
+
async fn healthz() -> Json<HealthDocument> {
Json(HealthDocument {
status: "ok".to_owned(),
@@ -244,14 +298,45 @@ async fn readyz(State(readiness): State<ReadinessState>) -> (StatusCode, Json<Re
(status, Json(readiness.response()))
}
+async fn relay_info(State(relay_info): State<RelayInfoDocument>, headers: HeaderMap) -> Response {
+ if !accepts_nostr_json(headers.get(header::ACCEPT)) {
+ return ApiError::not_found("relay information requires application/nostr+json")
+ .into_response();
+ }
+ (
+ StatusCode::OK,
+ [(
+ header::CONTENT_TYPE,
+ HeaderValue::from_static("application/nostr+json"),
+ )],
+ Json(relay_info),
+ )
+ .into_response()
+}
+
+fn accepts_nostr_json(value: Option<&HeaderValue>) -> bool {
+ value
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| {
+ value.split(',').any(|part| {
+ part.split(';').next().is_some_and(|media_type| {
+ media_type
+ .trim()
+ .eq_ignore_ascii_case("application/nostr+json")
+ })
+ })
+ })
+}
+
#[cfg(test)]
mod tests {
use super::{
ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope, ReadinessCheckStatus,
- ReadinessState, health_router,
+ ReadinessState, RelayInfoDocument, TANGLE_RELAY_SOFTWARE, TANGLE_SUPPORTED_NIPS,
+ health_router, relay_info_router,
};
use axum::{body::Body, response::IntoResponse};
- use http::{Request, StatusCode};
+ use http::{HeaderValue, Request, StatusCode, header};
use tower::ServiceExt;
#[test]
@@ -418,4 +503,115 @@ mod tests {
})
);
}
+
+ #[test]
+ fn relay_info_default_matches_mvp_protocol_claims() {
+ let relay_info = RelayInfoDocument::tangle_default();
+ assert_eq!(relay_info.name, "tangle");
+ assert_eq!(relay_info.supported_nips, TANGLE_SUPPORTED_NIPS);
+ assert_eq!(relay_info.software, TANGLE_RELAY_SOFTWARE);
+ assert_eq!(relay_info.version, "0.1.0");
+ assert_eq!(relay_info.limitation.payment_required, false);
+ assert_eq!(relay_info.limitation.restricted_writes, true);
+ assert_eq!(
+ serde_json::to_value(relay_info).expect("json"),
+ serde_json::json!({
+ "name": "tangle",
+ "description": "SurrealDB-backed Nostr relay for NIP-99 marketplaces",
+ "supported_nips": [1, 9, 11, 16, 33, 42, 50, 99],
+ "software": "https://github.com/radrootslabs/tangle",
+ "version": "0.1.0",
+ "limitation": {
+ "payment_required": false,
+ "restricted_writes": true
+ }
+ })
+ );
+ }
+
+ #[tokio::test]
+ async fn relay_info_endpoint_requires_nostr_accept_header() {
+ let response = relay_info_router(RelayInfoDocument::tangle_default())
+ .oneshot(
+ Request::builder()
+ .uri("/")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
+ serde_json::json!({
+ "error": {
+ "code": "not_found",
+ "message": "relay information requires application/nostr+json"
+ }
+ })
+ );
+ }
+
+ #[tokio::test]
+ async fn relay_info_endpoint_serves_nip11_document_for_nostr_accept() {
+ let response = relay_info_router(RelayInfoDocument::tangle_default())
+ .oneshot(
+ Request::builder()
+ .uri("/")
+ .header(
+ header::ACCEPT,
+ "text/plain, APPLICATION/NOSTR+JSON; charset=utf-8",
+ )
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .expect("content-type"),
+ HeaderValue::from_static("application/nostr+json")
+ );
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
+ serde_json::json!({
+ "name": "tangle",
+ "description": "SurrealDB-backed Nostr relay for NIP-99 marketplaces",
+ "supported_nips": [1, 9, 11, 16, 33, 42, 50, 99],
+ "software": "https://github.com/radrootslabs/tangle",
+ "version": "0.1.0",
+ "limitation": {
+ "payment_required": false,
+ "restricted_writes": true
+ }
+ })
+ );
+ }
+
+ #[tokio::test]
+ async fn relay_info_endpoint_rejects_invalid_accept_header() {
+ let response = relay_info_router(RelayInfoDocument::tangle_default())
+ .oneshot(
+ Request::builder()
+ .uri("/")
+ .header(
+ header::ACCEPT,
+ HeaderValue::from_bytes(b"\xff").expect("header"),
+ )
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ }
}