Skip to main content

netget/client/nostr/
api.rs

1//! Selected NIP-01 schemas. The existing pure wire module owns event crypto.
2use crate::server::nostr::wire::{self, Event, Filter};
3use anyhow::{ensure, Context, Result};
4use serde_json::{json, Map, Value};
5pub const MAX_DEPTH: usize = 8;
6pub const MAX_NODES: usize = 25000;
7pub const MAX_RETAINED_BYTES: usize = 2 * 1024 * 1024;
8pub const MAX_LIST: usize = 500;
9pub const MAX_REASON: usize = 4096;
10pub const MAX_TAG_ITEMS: usize = 32;
11pub const MAX_TEXT: usize = 64 * 1024;
12
13pub fn within_budget(value: &Value) -> bool {
14    crate::utils::json_budget::within_budget(value, MAX_RETAINED_BYTES, MAX_NODES, MAX_DEPTH)
15}
16fn fields(value: &Value, names: &[&str]) -> Result<()> {
17    ensure!(
18        value
19            .as_object()
20            .context("Nostr action must be object")?
21            .keys()
22            .all(|key| names.contains(&key.as_str())),
23        "unsupported Nostr action field"
24    );
25    Ok(())
26}
27pub fn subid(value: &Value) -> Result<String> {
28    let id = value
29        .as_str()
30        .context("Nostr subscription id must be string")?;
31    ensure!(
32        !id.is_empty() && id.chars().count() <= wire::MAX_SUBSCRIPTION_ID_CHARS,
33        "Nostr subscription id must be1..64 characters"
34    );
35    Ok(id.into())
36}
37fn lower_hex(value: &str, length: usize) -> bool {
38    value.len() == length
39        && value
40            .bytes()
41            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
42}
43pub enum Action {
44    Publish {
45        kind: u64,
46        content: String,
47        tags: Vec<Vec<String>>,
48        created_at: u64,
49    },
50    Subscribe {
51        id: String,
52        filters: Vec<Filter>,
53    },
54    Close(String),
55    RelayInfo,
56    Disconnect,
57}
58pub fn action(value: &Value) -> Result<Action> {
59    ensure!(
60        within_budget(value),
61        "Nostr action depth/node/retained-content limit"
62    );
63    match value["type"]
64        .as_str()
65        .context("Nostr action type required")?
66    {
67        "nostr_publish" => {
68            fields(value, &["type", "kind", "content", "tags", "created_at"])?;
69            let kind = value["kind"]
70                .as_u64()
71                .context("Nostr kind must be integer")?;
72            ensure!(kind <= wire::MAX_KIND, "Nostr kind must be0..65535");
73            let content = value["content"]
74                .as_str()
75                .context("Nostr content must be string")?;
76            ensure!(content.len() <= MAX_TEXT, "Nostr content limit");
77            let created_at = value
78                .get("created_at")
79                .map(|v| {
80                    v.as_u64()
81                        .context("Nostr created_at must be nonnegative integer")
82                })
83                .transpose()?
84                .unwrap_or_else(wire::now_secs);
85            let mut tags = Vec::new();
86            if let Some(value) = value.get("tags") {
87                let array = value.as_array().context("Nostr tags must be array")?;
88                ensure!(array.len() <= wire::MAX_TAGS, "Nostr tag count limit");
89                for tag in array {
90                    let tag = tag.as_array().context("Nostr tag must be string array")?;
91                    ensure!(
92                        !tag.is_empty() && tag.len() <= MAX_TAG_ITEMS,
93                        "Nostr tag item limit"
94                    );
95                    tags.push(
96                        tag.iter()
97                            .map(|item| {
98                                let text =
99                                    item.as_str().context("Nostr tag item must be string")?;
100                                ensure!(text.len() <= MAX_REASON, "Nostr tag text limit");
101                                Ok(text.into())
102                            })
103                            .collect::<Result<Vec<String>>>()?,
104                    );
105                }
106            }
107            Ok(Action::Publish {
108                kind,
109                content: content.into(),
110                tags,
111                created_at,
112            })
113        }
114        "nostr_subscribe" => {
115            fields(value, &["type", "subscription_id", "filters"])?;
116            let id = subid(&value["subscription_id"])?;
117            let values = value["filters"]
118                .as_array()
119                .context("Nostr filters must be array")?;
120            ensure!(
121                !values.is_empty() && values.len() <= wire::MAX_FILTERS,
122                "Nostr filter count must be1..10"
123            );
124            let filters = values.iter().map(filter).collect::<Result<Vec<_>>>()?;
125            let mut frame = vec![json!("REQ"), json!(id)];
126            frame.extend(filters.iter().map(|f| f.raw.clone()));
127            frame_text(Value::Array(frame))?;
128            Ok(Action::Subscribe { id, filters })
129        }
130        "nostr_close" => {
131            fields(value, &["type", "subscription_id"])?;
132            Ok(Action::Close(subid(&value["subscription_id"])?))
133        }
134        "nostr_relay_info" => {
135            fields(value, &["type"])?;
136            Ok(Action::RelayInfo)
137        }
138        "disconnect" => {
139            fields(value, &["type"])?;
140            Ok(Action::Disconnect)
141        }
142        _ => anyhow::bail!("unsupported selected Nostr action"),
143    }
144}
145pub fn filter(value: &Value) -> Result<Filter> {
146    ensure!(within_budget(value), "Nostr filter budget");
147    let fields = value.as_object().context("Nostr filter must be object")?;
148    ensure!(fields.len() <= 64, "Nostr filter field limit");
149    for (name, value) in fields {
150        if matches!(name.as_str(), "since" | "until" | "limit") {
151            let n = value
152                .as_u64()
153                .context("Nostr filter scalar must be nonnegative integer")?;
154            ensure!(
155                name != "limit" || n <= MAX_LIST as u64,
156                "Nostr initial limit must be0..500"
157            );
158        } else {
159            ensure!(
160                matches!(name.as_str(), "ids" | "authors" | "kinds")
161                    || name.len() == 2
162                        && name.starts_with('#')
163                        && name.as_bytes()[1].is_ascii_alphabetic(),
164                "unsupported Nostr filter extension"
165            );
166            let list = value
167                .as_array()
168                .context("Nostr filter list must be array")?;
169            ensure!(
170                !list.is_empty() && list.len() <= MAX_LIST,
171                "Nostr filter list must have1..500 values"
172            );
173            if name != "kinds" {
174                for v in list {
175                    let text = v.as_str().context("Nostr filter value must be string")?;
176                    ensure!(text.len() <= MAX_REASON, "Nostr filter text limit");
177                    if matches!(name.as_str(), "#e" | "#p") {
178                        ensure!(
179                            lower_hex(text, 64),
180                            "Nostr reference filter requires full lowercase hex"
181                        );
182                    }
183                }
184            }
185        }
186    }
187    wire::parse_filter(value).map_err(|_| anyhow::anyhow!("invalid NIP-01 filter"))
188}
189pub fn frame_text(frame: Value) -> Result<String> {
190    if !within_budget(&frame) {
191        crate::utils::json_budget::drop_iteratively(frame);
192        anyhow::bail!("Nostr frame depth/node/retained-content limit");
193    }
194    let text = serde_json::to_string(&frame)?;
195    ensure!(
196        text.len() <= wire::MAX_MESSAGE_BYTES,
197        "Nostr message byte limit"
198    );
199    Ok(text)
200}
201pub fn json(text: &[u8]) -> Result<Value> {
202    ensure!(
203        text.len() <= wire::MAX_MESSAGE_BYTES,
204        "Nostr message byte limit"
205    );
206    let value: Value = serde_json::from_slice(text).context("invalid Nostr JSON")?;
207    if !within_budget(&value) {
208        crate::utils::json_budget::drop_iteratively(value);
209        anyhow::bail!("Nostr JSON depth/node/retained-content limit");
210    }
211    Ok(value)
212}
213pub enum RelayMessage {
214    Event {
215        id: String,
216        event: Event,
217    },
218    Ok {
219        id: String,
220        accepted: bool,
221        message: String,
222    },
223    Eose(String),
224    Closed {
225        id: String,
226        message: String,
227    },
228    Notice(String),
229    Unsupported(&'static str),
230}
231fn reason(value: &Value) -> Result<String> {
232    let text = value
233        .as_str()
234        .context("Nostr relay reason must be string")?;
235    ensure!(text.len() <= MAX_REASON, "Nostr relay reason limit");
236    Ok(text.into())
237}
238pub fn relay(text: &str) -> Result<RelayMessage> {
239    let value = json(text.as_bytes())?;
240    let array = value
241        .as_array()
242        .context("Nostr relay message must be array")?;
243    match array
244        .first()
245        .and_then(Value::as_str)
246        .context("Nostr relay verb required")?
247    {
248        "EVENT" => {
249            ensure!(array.len() == 3, "Nostr EVENT arity");
250            let id = subid(&array[1])?;
251            let event = wire::verify_event(&array[2]).map_err(|_| {
252                anyhow::anyhow!("Nostr event id/signature/schema validation failed")
253            })?;
254            ensure!(
255                event.content.len() <= MAX_TEXT
256                    && event.tags.iter().all(|t| !t.is_empty()
257                        && t.len() <= MAX_TAG_ITEMS
258                        && t.iter().all(|s| s.len() <= MAX_REASON)),
259                "Nostr incoming event content/tag limit"
260            );
261            Ok(RelayMessage::Event { id, event })
262        }
263        "OK" => {
264            ensure!(array.len() == 4, "Nostr OK arity");
265            let id = array[1].as_str().context("Nostr OK event id required")?;
266            ensure!(
267                lower_hex(id, 64),
268                "Nostr OK event id must be full lowercase hex"
269            );
270            let accepted = array[2]
271                .as_bool()
272                .context("Nostr OK acceptance must be boolean")?;
273            let message = reason(&array[3])?;
274            if !accepted {
275                ensure!(
276                    reason_prefix(&message).is_some(),
277                    "Nostr rejection requires machine-readable prefix"
278                );
279            }
280            Ok(RelayMessage::Ok {
281                id: id.into(),
282                accepted,
283                message,
284            })
285        }
286        "EOSE" => {
287            ensure!(array.len() == 2, "Nostr EOSE arity");
288            Ok(RelayMessage::Eose(subid(&array[1])?))
289        }
290        "CLOSED" => {
291            ensure!(array.len() == 3, "Nostr CLOSED arity");
292            let message = reason(&array[2])?;
293            ensure!(
294                reason_prefix(&message).is_some(),
295                "Nostr CLOSED requires machine-readable prefix"
296            );
297            Ok(RelayMessage::Closed {
298                id: subid(&array[1])?,
299                message,
300            })
301        }
302        "NOTICE" => {
303            ensure!(array.len() == 2, "Nostr NOTICE arity");
304            Ok(RelayMessage::Notice(reason(&array[1])?))
305        }
306        // Unsupported extensions never trigger signing or expose challenges.
307        "AUTH" => Ok(RelayMessage::Unsupported("AUTH")),
308        "COUNT" => Ok(RelayMessage::Unsupported("COUNT")),
309        _ => Ok(RelayMessage::Unsupported("unknown")),
310    }
311}
312pub fn reason_prefix(text: &str) -> Option<&str> {
313    let (prefix, _) = text.split_once(':')?;
314    (!prefix.is_empty()
315        && prefix.len() <= 64
316        && prefix.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'))
317    .then_some(prefix)
318}
319pub fn relay_info(value: &Value) -> Result<Value> {
320    ensure!(within_budget(value), "Nostr information budget");
321    let input = value
322        .as_object()
323        .context("NIP-11 document must be object")?;
324    let mut output = Map::new();
325    for name in [
326        "name",
327        "description",
328        "banner",
329        "icon",
330        "contact",
331        "software",
332        "version",
333        "terms_of_service",
334        "pubkey",
335        "self",
336    ] {
337        if let Some(value) = input.get(name) {
338            if value.is_null() {
339                continue;
340            }
341            let text = value
342                .as_str()
343                .context("NIP-11 selected text field must be string")?;
344            ensure!(text.len() <= MAX_TEXT, "NIP-11 text limit");
345            if matches!(name, "pubkey" | "self") {
346                ensure!(
347                    lower_hex(text, 64),
348                    "NIP-11 public key must be full lowercase hex"
349                );
350            }
351            output.insert(name.into(), value.clone());
352        }
353    }
354    if let Some(value) = input.get("supported_nips").filter(|v| !v.is_null()) {
355        let list = value
356            .as_array()
357            .context("NIP-11 supported_nips must be array")?;
358        ensure!(
359            list.len() <= MAX_LIST && list.iter().all(|v| v.as_u64().is_some()),
360            "NIP-11 supported_nips integer list limit"
361        );
362        output.insert("supported_nips".into(), value.clone());
363    }
364    if let Some(value) = input.get("limitation").filter(|v| !v.is_null()) {
365        let input = value
366            .as_object()
367            .context("NIP-11 limitation must be object")?;
368        let mut selected = Map::new();
369        for name in [
370            "max_message_length",
371            "max_subscriptions",
372            "max_filters",
373            "max_limit",
374            "max_subid_length",
375            "max_event_tags",
376            "max_content_length",
377            "min_pow_difficulty",
378            "created_at_lower_limit",
379            "created_at_upper_limit",
380            "default_limit",
381        ] {
382            if let Some(value) = input.get(name).filter(|v| !v.is_null()) {
383                ensure!(
384                    value.as_u64().is_some(),
385                    "NIP-11 limitation integer required"
386                );
387                selected.insert(name.into(), value.clone());
388            }
389        }
390        for name in ["auth_required", "payment_required", "restricted_writes"] {
391            if let Some(value) = input.get(name).filter(|v| !v.is_null()) {
392                ensure!(
393                    value.as_bool().is_some(),
394                    "NIP-11 limitation boolean required"
395                );
396                selected.insert(name.into(), value.clone());
397            }
398        }
399        output.insert("limitation".into(), Value::Object(selected));
400    }
401    Ok(Value::Object(output))
402}