Files
ngit-grasp/src/http/nip11.rs
T
DanConwayDev aa543d8051 feat(relay): make discoverable hardening limits explicit
Motivation: rust-nostr 0.45 introduced a broad local-relay hardening series
whose effective defaults were mostly absent from its changelog and entirely
absent from ngit-grasp's custom NIP-11 response. One new 64 KiB event bound is
incompatible with production history containing a valid roughly 149 KiB
NIP-34 patch event. Leaving other defaults implicit risks another dependency
upgrade silently changing serving policy, while exposing every internal knob
would create configuration that peers cannot usefully negotiate.

Approach: explicitly select every retained rust-nostr hardening value in the
builder. Expose only the subscription and per-filter result limits that peers
can discover and ngit-grasp sync already consumes, plus the Git-specific event
size policy. Apply one filter-limit option consistently to explicit, query,
and omitted-limit caps. Raise the event default from 64 KiB to 192 KiB and
validate it remains beneath the fixed 5 MiB WebSocket message ceiling. Publish
the standard NIP-11 limitation fields and correct the architecture/reference
documentation, including removal of the obsolete max_filters claim.

Correctness: the NIP-11 max_subscriptions value feeds the existing per-session
subscription ledger; default_limit feeds adaptive pagination with its existing
verification-page safeguard. max_limit is advertised truthfully but is not
misused as an omitted-filter promise. The 192 KiB bound clears the observed
patch by about 29% while preserving a finite allocation boundary. All three
new options are synchronized across source, reference docs, NixOS, and the
environment example.

Excluded scope: message-size negotiation, filter-payload limits, per-IP
fairness, and non-standard NIP-11 extensions remain separate work. Rate,
handshake, subscription-memory, filter-count, and negentropy bounds are pinned
but deliberately not operator-configurable because our sync cannot negotiate
them through standard NIP-11 fields.

Validation: focused unit tests passed for explicit configuration defaults,
event/WebSocket size validation, configured NIP-11 advertisement, and the full
http::nip11::tests module (10 tests before adding the focused override case).
The previously validated full library suite and deployment build were not
repeated at the user's request.
2026-08-07 07:44:31 +00:00

400 lines
15 KiB
Rust

use crate::config::Config;
/// NIP-11 Relay Information Document
///
/// Implements NIP-11 relay information endpoint with GRASP-01 extensions.
/// See: https://github.com/nostr-protocol/nips/blob/master/11.md
use serde::{Deserialize, Serialize};
/// NIP-11 Relay Information Document
///
/// This structure represents the relay metadata served at the HTTP(S) endpoint
/// when the client sends `Accept: application/nostr+json` header.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayInformationDocument {
/// Relay name
pub name: String,
/// Relay description
pub description: String,
/// Relay owner's public key (hex format)
#[serde(skip_serializing_if = "Option::is_none")]
pub pubkey: Option<String>,
/// Contact information for relay admin
#[serde(skip_serializing_if = "Option::is_none")]
pub contact: Option<String>,
/// List of NIPs supported by this relay
pub supported_nips: Vec<u16>,
/// Relay software identifier
pub software: String,
/// Software version
pub version: String,
/// Relay icon URL (NIP-11 optional field)
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// Standard NIP-11 limits enforced by the embedded relay.
pub limitation: RelayLimitation,
// GRASP-01 Extensions (lines 24-28 of GRASP-01 spec)
/// List of supported GRASPs (e.g., ["GRASP-01"])
/// Required by GRASP-01 specification line 26
pub supported_grasps: Vec<String>,
/// Repository acceptance criteria description
/// Required by GRASP-01 specification line 27
pub repo_acceptance_criteria: String,
/// Curation policy (present if curated, absent otherwise)
/// Required by GRASP-01 specification line 28 when events are curated
#[serde(skip_serializing_if = "Option::is_none")]
pub curation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayLimitation {
pub max_message_length: usize,
pub max_subscriptions: usize,
pub max_limit: usize,
pub max_subid_length: usize,
pub default_limit: usize,
pub restricted_writes: bool,
}
impl RelayInformationDocument {
/// Create NIP-11 relay information document from configuration
pub fn from_config(config: &Config) -> Self {
// Get validated configuration (config.validate() must be called at startup)
let archive_config = config.archive_config();
let archive_enabled = archive_config.enabled();
let archive_read_only = archive_config.read_only;
// Build supported_grasps list
let mut supported_grasps = vec!["GRASP-01".to_string()];
if archive_enabled {
supported_grasps.push("GRASP-05".to_string());
}
supported_grasps.push("GRASP-02".to_string());
if config.grasp06_enable {
supported_grasps.push("GRASP-06".to_string());
}
// Build curation field for archive read-only mode or repository whitelist
let repository_config = config.repository_config();
let repository_whitelist_enabled = repository_config.enabled();
let curation = if archive_read_only {
// Archive read-only mode (GRASP-05 only)
if archive_config.archive_all {
Some("Read-only sync of all repositories found on network".to_string())
} else if !archive_config.whitelist.is_empty() {
Some("Read-only sync of whitelisted repositories and maintainers".to_string())
} else {
None
}
} else if archive_enabled && repository_whitelist_enabled {
// Both archive (non-read-only) AND repository whitelist enabled
Some(
"Accepts whitelisted repositories (with or without service listing) and whitelisted repositories that list this service"
.to_string(),
)
} else if repository_whitelist_enabled {
// Repository whitelist only
Some(
"Accepts only whitelisted repositories and maintainers that list this service"
.to_string(),
)
} else {
None
};
Self {
name: config.relay_name(),
description: config.relay_description.clone(),
pubkey: config.relay_owner_pubkey_hex().ok(),
contact: None, // Could be added to config if needed
supported_nips: {
let mut nips = vec![
1, // NIP-01: Basic protocol flow
11, // NIP-11: Relay information document (this!)
34, // NIP-34: Git repository announcements
77, // NIP-77: Negentropy sync (reconciliation protocol)
];
// NIP-09 (deletion) and NIP-62 (request to vanish) are honoured
// only when not running as an archival "disrespector" relay. When
// disrespector mode is on the relay stores but ignores deletion /
// vanish requests, so we must not advertise NIP-09 or NIP-62
// support — clients can then discover that this relay does not
// honour deletions.
if !config.deletion_request_disrespector {
nips.push(9); // NIP-09: Event deletion requests
nips.push(62); // NIP-62: Request to vanish
nips.sort_unstable();
}
nips
},
software: "https://gitworkshop.dev/danconwaydev.com/ngit-grasp".to_string(),
version: match option_env!("GIT_COMMIT_SHORT") {
Some(commit) => format!("{}-{}", env!("CARGO_PKG_VERSION"), commit),
None => env!("CARGO_PKG_VERSION").to_string(),
},
icon: Some(format!("https://{}/icon.png", config.domain)),
limitation: RelayLimitation {
max_message_length: 5 * 1024 * 1024,
max_subscriptions: config.relay_max_subscriptions,
max_limit: config.relay_filter_limit,
max_subid_length: 250,
default_limit: config.relay_filter_limit,
restricted_writes: true,
},
// GRASP Extensions
supported_grasps,
repo_acceptance_criteria: "None".to_string(),
curation,
}
}
/// Serialize to JSON string
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::nips::nip19::ToBech32;
#[test]
fn test_relay_information_document_structure() {
let mut config = Config::for_testing();
config.domain = "relay.example.com".to_string();
config.relay_name_override = Some("Test Relay".to_string());
config.relay_description = "A test relay".to_string();
let doc = RelayInformationDocument::from_config(&config);
assert_eq!(doc.name, "Test Relay");
assert_eq!(doc.description, "A test relay");
// Verify pubkey is present and is a valid hex string (64 chars)
assert!(doc.pubkey.is_some());
let pubkey = doc.pubkey.unwrap();
assert_eq!(pubkey.len(), 64);
assert!(pubkey.chars().all(|c| c.is_ascii_hexdigit()));
assert!(doc.supported_nips.contains(&1));
assert!(doc.supported_nips.contains(&11));
assert!(doc.supported_nips.contains(&34));
assert!(doc.supported_nips.contains(&77));
// NIP-09 (deletion) and NIP-62 (vanish) advertised by default
// (disrespector off).
assert!(doc.supported_nips.contains(&9));
assert!(doc.supported_nips.contains(&62));
// Without archive mode, only GRASP-01 and GRASP-02
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
assert!(doc.repo_acceptance_criteria.contains("None"));
assert!(doc.curation.is_none());
assert_eq!(
doc.icon,
Some("https://relay.example.com/icon.png".to_string())
);
assert_eq!(doc.limitation.max_subscriptions, 500);
assert_eq!(doc.limitation.max_limit, 500);
assert_eq!(doc.limitation.default_limit, 500);
assert_eq!(doc.limitation.max_message_length, 5 * 1024 * 1024);
assert!(doc.limitation.restricted_writes);
}
#[test]
fn test_relay_information_document_json() {
let mut config = Config::for_testing();
config.domain = "relay.example.com".to_string();
config.relay_name_override = Some("Test Relay".to_string());
config.relay_description = "A test relay".to_string();
let doc = RelayInformationDocument::from_config(&config);
let json = doc.to_json().expect("Failed to serialize to JSON");
// Verify JSON contains expected fields
assert!(json.contains("\"name\""));
assert!(json.contains("\"description\""));
assert!(json.contains("\"supported_nips\""));
assert!(json.contains("\"supported_grasps\""));
assert!(json.contains("\"repo_acceptance_criteria\""));
assert!(json.contains("GRASP-01"));
assert!(json.contains("GRASP-02"));
// Verify it's valid JSON by parsing
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Invalid JSON");
assert_eq!(parsed["name"], "Test Relay");
assert_eq!(parsed["supported_grasps"][0], "GRASP-01");
assert_eq!(parsed["supported_grasps"][1], "GRASP-02");
assert_eq!(parsed["icon"], "https://relay.example.com/icon.png");
assert_eq!(parsed["limitation"]["max_subscriptions"], 500);
assert_eq!(parsed["limitation"]["max_limit"], 500);
assert_eq!(parsed["limitation"]["default_limit"], 500);
}
#[test]
fn test_nip11_advertises_configured_sync_limits() {
let mut config = Config::for_testing();
config.relay_max_subscriptions = 20;
config.relay_filter_limit = 300;
let doc = RelayInformationDocument::from_config(&config);
assert_eq!(doc.limitation.max_subscriptions, 20);
assert_eq!(doc.limitation.max_limit, 300);
assert_eq!(doc.limitation.default_limit, 300);
}
#[test]
fn test_nip11_with_archive_mode() {
let mut config = Config::for_testing();
config.domain = "relay.example.com".to_string();
config.relay_name_override = Some("Archive Relay".to_string());
config.archive_all = true;
config.archive_read_only = Some(true);
let doc = RelayInformationDocument::from_config(&config);
// Archive mode enabled: should include GRASP-05
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
);
// Archive read-only: should have curation field
assert!(doc.curation.is_some());
assert!(doc
.curation
.unwrap()
.contains("Read-only sync of all repositories"));
}
#[test]
fn test_nip11_with_whitelist_archive() {
let keys = nostr_sdk::prelude::Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let mut config = Config::for_testing();
config.domain = "relay.example.com".to_string();
config.archive_whitelist = format!("{},bitcoin-core", test_npub);
let doc = RelayInformationDocument::from_config(&config);
// Archive whitelist enabled: should include GRASP-05
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
);
// Archive read-only defaults to true: should have curation field
assert!(doc.curation.is_some());
assert!(doc
.curation
.unwrap()
.contains("Read-only sync of whitelisted"));
}
#[test]
fn test_nip11_with_repository_whitelist() {
let keys = nostr_sdk::prelude::Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let mut config = Config::for_testing();
config.domain = "relay.example.com".to_string();
config.repository_whitelist = format!("{},bitcoin-core", test_npub);
let doc = RelayInformationDocument::from_config(&config);
// Repository whitelist doesn't enable GRASP-05
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
// Should have curation field for repository whitelist
assert!(doc.curation.is_some());
assert!(doc
.curation
.unwrap()
.contains("Accepts only whitelisted repositories"));
}
#[test]
fn test_nip11_with_archive_and_repository_whitelist() {
let keys = nostr_sdk::prelude::Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let mut config = Config::for_testing();
config.domain = "relay.example.com".to_string();
config.archive_whitelist = "bitcoin-core".to_string();
config.archive_read_only = Some(false); // Non-read-only archive mode
config.repository_whitelist = test_npub;
let doc = RelayInformationDocument::from_config(&config);
// Should have GRASP-05 enabled due to archive whitelist
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
);
// Should have curation field reflecting BOTH archive and repository whitelist
assert!(doc.curation.is_some());
let curation = doc.curation.unwrap();
assert!(curation.contains("whitelisted repositories"));
assert!(curation.contains("with or without service listing"));
}
#[test]
fn test_nip11_grasp_06_disabled_by_default() {
let config = Config::for_testing();
let doc = RelayInformationDocument::from_config(&config);
// GRASP-06 must not be advertised when the flag is off.
assert!(!doc.supported_grasps.iter().any(|g| g == "GRASP-06"));
}
#[test]
fn test_nip11_advertises_grasp_06_when_enabled() {
let mut config = Config::for_testing();
config.grasp06_enable = true;
let doc = RelayInformationDocument::from_config(&config);
// GRASP-06 must be advertised when the flag is on, alongside the
// standard GRASP-01 / GRASP-02 entries. Order is "GRASP-06 last"
// to match the insertion order in from_config().
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-02", "GRASP-06"]
);
}
#[test]
fn test_nip11_advertises_deletion_by_default() {
let config = Config::for_testing();
let doc = RelayInformationDocument::from_config(&config);
// NIP-09 (deletion) and NIP-62 (vanish) are honoured (and advertised)
// by default.
assert!(doc.supported_nips.contains(&9));
assert!(doc.supported_nips.contains(&62));
}
#[test]
fn test_nip11_omits_deletion_in_disrespector_mode() {
let mut config = Config::for_testing();
config.deletion_request_disrespector = true;
let doc = RelayInformationDocument::from_config(&config);
// Archival relays do not honour deletions, so NIP-09 and NIP-62 must
// not be advertised.
assert!(!doc.supported_nips.contains(&9));
assert!(!doc.supported_nips.contains(&62));
// Other NIPs are unaffected.
assert!(doc.supported_nips.contains(&1));
assert!(doc.supported_nips.contains(&34));
}
}