Skip to main content

nula_core/nips/
nip86.rs

1//! [NIP-86] Relay Management API.
2//!
3//! JSON-RPC-like protocol over HTTP for relay administration. The wire
4//! envelope is:
5//!
6//! ```jsonc
7//! { "method": "<method-name>", "params": ["<array>", "<of>", "<parameters>"] }
8//! ```
9//!
10//! and responses are:
11//!
12//! ```jsonc
13//! { "result": <arbitrary>, "error": "<optional error message>" }
14//! ```
15//!
16//! This module exposes the typed [`Method`] enum (every method named
17//! by the spec, plus a forward-compatible [`Method::Custom`]),
18//! [`Request`] / [`Response`] structs that round-trip through the
19//! wire JSON, and [`PubkeyEntry`] / [`EventEntry`] / [`IpEntry`]
20//! result rows.
21//!
22//! Authorization MUST be provided via a NIP-98 HTTP-auth event with
23//! the `payload` and `u` tags filled in; this module only models the
24//! payload shape and leaves the HTTP transport to the caller.
25//!
26//! [NIP-86]: https://github.com/nostr-protocol/nips/blob/master/86.md
27
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31use crate::event::Kind;
32use crate::key::{PublicKey, PublicKeyError};
33use crate::types::{Url, UrlError};
34
35/// HTTP `Content-Type` the spec requires.
36pub const CONTENT_TYPE: &str = "application/nostr+json+rpc";
37
38/// Wire envelope for a NIP-86 request.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct Request {
41    /// Method token.
42    pub method: String,
43    /// Parameter array (mixed-type per spec).
44    #[serde(default)]
45    pub params: Vec<serde_json::Value>,
46}
47
48/// Wire envelope for a NIP-86 response.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct Response {
51    /// Result payload (`null` on error).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub result: Option<serde_json::Value>,
54    /// Optional error message.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub error: Option<String>,
57}
58
59impl Response {
60    /// True when [`Self::error`] is set.
61    #[must_use]
62    pub const fn is_error(&self) -> bool {
63        self.error.is_some()
64    }
65
66    /// Construct a successful response.
67    #[must_use]
68    pub const fn ok(result: serde_json::Value) -> Self {
69        Self {
70            result: Some(result),
71            error: None,
72        }
73    }
74
75    /// Construct an error response.
76    #[must_use]
77    pub fn err(message: impl Into<String>) -> Self {
78        Self {
79            result: None,
80            error: Some(message.into()),
81        }
82    }
83}
84
85/// Spec-listed methods plus a forward-compatible passthrough.
86#[derive(Debug, Clone, PartialEq, Eq, Hash)]
87pub enum Method {
88    /// `supportedmethods` — server enumerates supported tokens.
89    SupportedMethods,
90    /// `banpubkey` — ban a pubkey.
91    BanPubkey,
92    /// `unbanpubkey` — undo a ban.
93    UnbanPubkey,
94    /// `listbannedpubkeys`.
95    ListBannedPubkeys,
96    /// `allowpubkey` — allowlist a pubkey.
97    AllowPubkey,
98    /// `unallowpubkey` — undo an allow.
99    UnallowPubkey,
100    /// `listallowedpubkeys`.
101    ListAllowedPubkeys,
102    /// `listeventsneedingmoderation`.
103    ListEventsNeedingModeration,
104    /// `allowevent`.
105    AllowEvent,
106    /// `banevent`.
107    BanEvent,
108    /// `listbannedevents`.
109    ListBannedEvents,
110    /// `changerelayname`.
111    ChangeRelayName,
112    /// `changerelaydescription`.
113    ChangeRelayDescription,
114    /// `changerelayicon`.
115    ChangeRelayIcon,
116    /// `allowkind` — accept a `kind` integer.
117    AllowKind,
118    /// `disallowkind`.
119    DisallowKind,
120    /// `listallowedkinds`.
121    ListAllowedKinds,
122    /// `blockip`.
123    BlockIp,
124    /// `unblockip`.
125    UnblockIp,
126    /// `listblockedips`.
127    ListBlockedIps,
128    /// Forward-compatible passthrough for non-spec tokens.
129    Custom(String),
130}
131
132impl Method {
133    /// Wire token.
134    #[must_use]
135    #[expect(
136        clippy::missing_const_for_fn,
137        reason = "`Self::Custom` borrows from a heap `String`"
138    )]
139    pub fn as_str(&self) -> &str {
140        match self {
141            Self::SupportedMethods => "supportedmethods",
142            Self::BanPubkey => "banpubkey",
143            Self::UnbanPubkey => "unbanpubkey",
144            Self::ListBannedPubkeys => "listbannedpubkeys",
145            Self::AllowPubkey => "allowpubkey",
146            Self::UnallowPubkey => "unallowpubkey",
147            Self::ListAllowedPubkeys => "listallowedpubkeys",
148            Self::ListEventsNeedingModeration => "listeventsneedingmoderation",
149            Self::AllowEvent => "allowevent",
150            Self::BanEvent => "banevent",
151            Self::ListBannedEvents => "listbannedevents",
152            Self::ChangeRelayName => "changerelayname",
153            Self::ChangeRelayDescription => "changerelaydescription",
154            Self::ChangeRelayIcon => "changerelayicon",
155            Self::AllowKind => "allowkind",
156            Self::DisallowKind => "disallowkind",
157            Self::ListAllowedKinds => "listallowedkinds",
158            Self::BlockIp => "blockip",
159            Self::UnblockIp => "unblockip",
160            Self::ListBlockedIps => "listblockedips",
161            Self::Custom(s) => s.as_str(),
162        }
163    }
164
165    /// Parse a wire token. Always succeeds.
166    #[must_use]
167    pub fn parse(token: &str) -> Self {
168        match token {
169            "supportedmethods" => Self::SupportedMethods,
170            "banpubkey" => Self::BanPubkey,
171            "unbanpubkey" => Self::UnbanPubkey,
172            "listbannedpubkeys" => Self::ListBannedPubkeys,
173            "allowpubkey" => Self::AllowPubkey,
174            "unallowpubkey" => Self::UnallowPubkey,
175            "listallowedpubkeys" => Self::ListAllowedPubkeys,
176            "listeventsneedingmoderation" => Self::ListEventsNeedingModeration,
177            "allowevent" => Self::AllowEvent,
178            "banevent" => Self::BanEvent,
179            "listbannedevents" => Self::ListBannedEvents,
180            "changerelayname" => Self::ChangeRelayName,
181            "changerelaydescription" => Self::ChangeRelayDescription,
182            "changerelayicon" => Self::ChangeRelayIcon,
183            "allowkind" => Self::AllowKind,
184            "disallowkind" => Self::DisallowKind,
185            "listallowedkinds" => Self::ListAllowedKinds,
186            "blockip" => Self::BlockIp,
187            "unblockip" => Self::UnblockIp,
188            "listblockedips" => Self::ListBlockedIps,
189            _ => Self::Custom(token.to_owned()),
190        }
191    }
192}
193
194/// `{"pubkey": "...", "reason": "..."}` row used by ban/allow lists.
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196pub struct PubkeyEntry {
197    /// 32-byte hex pubkey.
198    pub pubkey: String,
199    /// Optional moderation reason.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub reason: Option<String>,
202}
203
204/// `{"id": "...", "reason": "..."}` row used by event ban / moderation
205/// queue lists.
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
207pub struct EventEntry {
208    /// 32-byte hex event id.
209    pub id: String,
210    /// Optional moderation reason.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub reason: Option<String>,
213}
214
215/// `{"ip": "...", "reason": "..."}` row used by IP block lists.
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
217pub struct IpEntry {
218    /// IP address (string form).
219    pub ip: String,
220    /// Optional moderation reason.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub reason: Option<String>,
223}
224
225/// Errors raised while building NIP-86 requests / parsing responses.
226#[derive(Debug, Error)]
227#[non_exhaustive]
228pub enum ManagementError {
229    /// Wrapped JSON serialisation error.
230    #[error(transparent)]
231    Json(#[from] serde_json::Error),
232    /// Wrapped pubkey parser error.
233    #[error(transparent)]
234    InvalidPublicKey(#[from] PublicKeyError),
235    /// Wrapped URL parser error.
236    #[error(transparent)]
237    InvalidUrl(#[from] UrlError),
238}
239
240/// Build a request that takes a `(pubkey, optional reason)` pair.
241#[must_use]
242pub fn pubkey_request(method: &Method, pubkey: &PublicKey, reason: Option<&str>) -> Request {
243    let mut params = vec![serde_json::Value::String(pubkey.to_hex())];
244    if let Some(r) = reason {
245        params.push(serde_json::Value::String(r.to_owned()));
246    }
247    Request {
248        method: method.as_str().to_owned(),
249        params,
250    }
251}
252
253/// Build a request that takes an `(event-id-hex, optional reason)`
254/// pair.
255#[must_use]
256pub fn event_request(method: &Method, event_id_hex: &str, reason: Option<&str>) -> Request {
257    let mut params = vec![serde_json::Value::String(event_id_hex.to_owned())];
258    if let Some(r) = reason {
259        params.push(serde_json::Value::String(r.to_owned()));
260    }
261    Request {
262        method: method.as_str().to_owned(),
263        params,
264    }
265}
266
267/// Build a request that takes a single `kind` integer (used by
268/// `allowkind` / `disallowkind`).
269#[must_use]
270pub fn kind_request(method: &Method, kind: Kind) -> Request {
271    Request {
272        method: method.as_str().to_owned(),
273        params: vec![serde_json::Value::Number(kind.as_u16().into())],
274    }
275}
276
277/// Build a request that takes a single `(ip, optional reason)` pair.
278#[must_use]
279pub fn ip_request(method: &Method, ip: &str, reason: Option<&str>) -> Request {
280    let mut params = vec![serde_json::Value::String(ip.to_owned())];
281    if let Some(r) = reason {
282        params.push(serde_json::Value::String(r.to_owned()));
283    }
284    Request {
285        method: method.as_str().to_owned(),
286        params,
287    }
288}
289
290/// Build a request that takes a single string (used by
291/// `changerelayname` / `description` / `icon`).
292#[must_use]
293pub fn string_request(method: &Method, value: impl Into<String>) -> Request {
294    Request {
295        method: method.as_str().to_owned(),
296        params: vec![serde_json::Value::String(value.into())],
297    }
298}
299
300/// Build a request that takes a single URL.
301#[must_use]
302pub fn url_request(method: &Method, url: &Url) -> Request {
303    string_request(method, url.as_str())
304}
305
306/// Build a parameterless request (used by all `list*` methods).
307#[must_use]
308pub fn empty_request(method: &Method) -> Request {
309    Request {
310        method: method.as_str().to_owned(),
311        params: Vec::new(),
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::Keys;
319
320    fn keys() -> Keys {
321        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
322    }
323
324    #[test]
325    fn ban_pubkey_request_roundtrip() {
326        let req = pubkey_request(&Method::BanPubkey, keys().public_key(), Some("spam"));
327        let json = serde_json::to_string(&req).unwrap();
328        assert!(json.contains("banpubkey"));
329        assert!(json.contains("spam"));
330        let parsed: Request = serde_json::from_str(&json).unwrap();
331        assert_eq!(parsed.method, "banpubkey");
332        assert_eq!(parsed.params.len(), 2);
333    }
334
335    #[test]
336    fn response_serialisation() {
337        let resp = Response::ok(serde_json::json!(true));
338        let json = serde_json::to_string(&resp).unwrap();
339        assert_eq!(json, r#"{"result":true}"#);
340        let err = Response::err("forbidden");
341        let json2 = serde_json::to_string(&err).unwrap();
342        assert!(json2.contains("forbidden"));
343    }
344
345    #[test]
346    fn method_round_trip_custom() {
347        assert_eq!(
348            Method::parse("nostr.relay.custom"),
349            Method::Custom("nostr.relay.custom".to_owned())
350        );
351    }
352
353    #[test]
354    fn pubkey_entry_roundtrip() {
355        let entry = PubkeyEntry {
356            pubkey: keys().public_key().to_hex(),
357            reason: Some("abuse".into()),
358        };
359        let json = serde_json::to_string(&entry).unwrap();
360        let parsed: PubkeyEntry = serde_json::from_str(&json).unwrap();
361        assert_eq!(parsed, entry);
362    }
363
364    #[test]
365    fn empty_request_has_no_params() {
366        let req = empty_request(&Method::SupportedMethods);
367        assert!(req.params.is_empty());
368    }
369}