mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
Allow operators to share an authority by configuring a normalized public base path. Route HTTP, WebSocket, Git, and GRASP-06 traffic beneath that prefix and carry the path through service validation, generated relay identity, self-sync, NIP-11 icons, and landing links. Treat NIP-05 as a domain-root capability: path-mounted services neither expose the well-known document nor advertise NIP-05 in NIP-11, and generated owner profiles omit the nip05 field. Exact relay URL matching keeps sibling mounts distinct while clone URLs may descend below the configured prefix. This assumes reverse proxies preserve the configured public prefix. Proxy-side prefix stripping remains deliberately out of scope. Validation: - nix develop -c cargo test - nix develop -c cargo clippy --all-targets -- -D warnings - cargo fmt --all -- --check - git diff --check - nix-instantiate --parse nix/module.nix
449 lines
17 KiB
Rust
449 lines
17 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 a NIP-11 document for the configured public mount path.
|
|
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.sync_plus_enabled {
|
|
supported_grasps.push("GRASP-03".to_string());
|
|
}
|
|
if config.grasp06_enable {
|
|
supported_grasps.push("GRASP-06".to_string());
|
|
}
|
|
if config.private_mode {
|
|
supported_grasps.push("GRASP-08".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)
|
|
];
|
|
if config.is_domain_root() {
|
|
nips.push(5); // NIP-05: Root-domain identity
|
|
}
|
|
// 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
|
|
}
|
|
if config.private_mode {
|
|
nips.push(42); // NIP-42: relay client authentication
|
|
nips.push(98); // NIP-98: HTTP authentication
|
|
}
|
|
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.service_address())),
|
|
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(&5));
|
|
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));
|
|
assert_eq!(
|
|
doc.supported_grasps,
|
|
vec!["GRASP-01", "GRASP-02", "GRASP-03"]
|
|
);
|
|
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["supported_grasps"][2], "GRASP-03");
|
|
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", "GRASP-03"]
|
|
);
|
|
// 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", "GRASP-03"]
|
|
);
|
|
// 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", "GRASP-03"]
|
|
);
|
|
// 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", "GRASP-03"]
|
|
);
|
|
// 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-03", "GRASP-06"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_nip11_omits_grasp_03_when_sync_plus_is_disabled() {
|
|
let mut config = Config::for_testing();
|
|
config.sync_plus_enabled = false;
|
|
|
|
let doc = RelayInformationDocument::from_config(&config);
|
|
|
|
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
|
|
}
|
|
|
|
#[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_advertises_nip05_only_at_domain_root() {
|
|
let root_config = Config::for_testing();
|
|
let nested_config = Config {
|
|
base_path: "/relay".to_string(),
|
|
..Config::for_testing()
|
|
};
|
|
|
|
let root_doc = RelayInformationDocument::from_config(&root_config);
|
|
let nested_doc = RelayInformationDocument::from_config(&nested_config);
|
|
|
|
assert!(root_doc.supported_nips.contains(&5));
|
|
assert!(!nested_doc.supported_nips.contains(&5));
|
|
assert_eq!(
|
|
nested_doc.icon.as_deref(),
|
|
Some("https://localhost:7334/relay/icon.png")
|
|
);
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|