mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
Refactors configuration validation to fail fast on fatal errors at startup while gracefully handling recoverable issues (e.g., malformed whitelist entries). Changes: - Add Config::validate() for eager validation called immediately after load - Remove Result<> from archive_config() and repository_config() methods - WhitelistEntry::parse_whitelist() skips invalid entries with warnings - Validate relay_owner_nsec format in Config::validate() - Update all call sites to remove Result handling from config getters Benefits: - Fatal config errors (incompatible settings) fail at startup, not runtime - Recoverable errors (bad whitelist entries) logged as warnings and skipped - No Result handling scattered throughout runtime code after validation - Config methods safe to call without error handling after validate() Testing: - Add 7 new tests for validation edge cases and error handling - Total config tests: 40 (up from 33) - All 320 library tests passing Breaking change: Config users must call config.validate() after Config::load() to ensure configuration is valid. This is enforced in main.rs.
284 lines
11 KiB
Rust
284 lines
11 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>,
|
|
|
|
// GRASP-01 Extensions (lines 11-14 of GRASP-01 spec)
|
|
/// List of supported GRASPs (e.g., ["GRASP-01"])
|
|
/// Required by GRASP-01 specification line 12
|
|
pub supported_grasps: Vec<String>,
|
|
|
|
/// Repository acceptance criteria description
|
|
/// Required by GRASP-01 specification line 13
|
|
pub repo_acceptance_criteria: String,
|
|
|
|
/// Curation policy (present if curated, absent otherwise)
|
|
/// Optional per GRASP-01 specification line 14
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub curation: Option<String>,
|
|
}
|
|
|
|
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());
|
|
|
|
// 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_npub().ok(),
|
|
contact: None, // Could be added to config if needed
|
|
supported_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)
|
|
],
|
|
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)),
|
|
|
|
// 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_sdk::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 npub
|
|
assert!(doc.pubkey.is_some());
|
|
let pubkey = doc.pubkey.unwrap();
|
|
assert!(pubkey.starts_with("npub1"));
|
|
|
|
assert!(doc.supported_nips.contains(&1));
|
|
assert!(doc.supported_nips.contains(&11));
|
|
assert!(doc.supported_nips.contains(&34));
|
|
assert!(doc.supported_nips.contains(&77));
|
|
// 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())
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
#[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::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::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::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"));
|
|
}
|
|
}
|