From a12927181c571fc1641772ad44dd4c6a4ab209d9 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 20:30:13 +0000 Subject: feat(grasp-05): add read-only mode with auto-enable for archive configs Implements NGIT_ARCHIVE_READ_ONLY configuration option that defaults to true when archive mode is enabled, allowing relays to operate as read-only syncs of archived repositories. Key changes: - Add NGIT_ARCHIVE_READ_ONLY config option (defaults to true if archive enabled) - NIP-11 advertises GRASP-05 support and includes curation field when read-only - Validation logic rejects non-whitelisted repos in read-only mode - Comprehensive tests for read-only behavior and defaults - Full documentation in config reference, .env.example, and NixOS module Read-only mode enables passive mirroring without being listed in announcements, useful for backup/archive operations while preventing accidental write acceptance. --- src/config.rs | 107 +++++++++++++++++++++++++++++++++++++++++++- src/http/nip11.rs | 87 +++++++++++++++++++++++++++++++++++- src/nostr/builder.rs | 5 ++- src/nostr/events.rs | 123 ++++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 306 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/config.rs b/src/config.rs index b1ab43e..d9917a3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -98,6 +98,13 @@ pub struct ArchiveConfig { /// /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). pub whitelist: Vec, + + /// Read-only archive mode: relay is a read-only sync of archived repositories + /// + /// When true, the relay ONLY accepts announcements matching the archive whitelist/all. + /// Announcements listing the relay but not in the whitelist are rejected. + /// When false, the relay operates in GRASP-01 mode for unwhitelisted repos. + pub read_only: bool, } impl ArchiveConfig { @@ -141,6 +148,7 @@ impl Default for ArchiveConfig { Self { archive_all: false, whitelist: Vec::new(), + read_only: false, } } } @@ -311,6 +319,12 @@ pub struct Config { /// Formats: "npub1...", "npub1.../identifier", "identifier" #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] pub archive_whitelist: String, + + /// Archive read-only mode: relay is a read-only sync of archived repositories + /// Defaults to true if archive_all or archive_whitelist is set, false otherwise + /// Throws error if set to true without archive_all or archive_whitelist + #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] + pub archive_read_only: Option, } impl Config { @@ -411,12 +425,34 @@ impl Config { } } - /// Get parsed archive configuration + /// Get parsed archive configuration with computed read-only mode + /// + /// Read-only mode defaults to true if archive mode is enabled, false otherwise. + /// Throws error if explicitly set to true without archive mode enabled. pub fn archive_config(&self) -> Result { let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; + let archive_enabled = self.archive_all || !whitelist.is_empty(); + + let read_only = match self.archive_read_only { + Some(true) => { + if !archive_enabled { + return Err(anyhow!( + "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" + )); + } + true + } + Some(false) => false, + None => { + // Default: true if archive mode enabled, false otherwise + archive_enabled + } + }; + Ok(ArchiveConfig { archive_all: self.archive_all, whitelist, + read_only, }) } @@ -452,6 +488,7 @@ impl Config { naughty_list_expiration_hours: 12, archive_all: false, archive_whitelist: String::new(), + archive_read_only: None, } } } @@ -664,12 +701,14 @@ mod tests { let config = ArchiveConfig { archive_all: true, whitelist: Vec::new(), + read_only: true, }; assert!(config.enabled()); let config = ArchiveConfig { archive_all: false, whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], + read_only: true, }; assert!(config.enabled()); } @@ -684,6 +723,7 @@ mod tests { ArchiveWhitelistEntry::Pubkey(test_npub.clone()), ArchiveWhitelistEntry::Identifier("bitcoin-core".into()), ], + read_only: false, }; assert!(config.matches(&test_npub, "any-repo")); @@ -696,6 +736,7 @@ mod tests { let config = ArchiveConfig { archive_all: true, whitelist: Vec::new(), + read_only: true, }; assert!(config.matches("npub1alice", "any-repo")); @@ -745,4 +786,68 @@ mod tests { }; assert!(config.archive_config().is_err()); } + + #[test] + fn test_archive_read_only_defaults() { + // Default: false when no archive mode + let config = Config::for_testing(); + assert_eq!(config.archive_config().unwrap().read_only, false); + + // Default: true when archive_all is set + let config = Config { + archive_all: true, + ..Config::for_testing() + }; + assert_eq!(config.archive_config().unwrap().read_only, true); + + // Default: true when archive_whitelist is set + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = Config { + archive_whitelist: test_npub, + ..Config::for_testing() + }; + assert_eq!(config.archive_config().unwrap().read_only, true); + } + + #[test] + fn test_archive_read_only_explicit() { + // Explicit true with archive_all + let config = Config { + archive_all: true, + archive_read_only: Some(true), + ..Config::for_testing() + }; + assert_eq!(config.archive_config().unwrap().read_only, true); + + // Explicit false with archive_all (unusual but allowed) + let config = Config { + archive_all: true, + archive_read_only: Some(false), + ..Config::for_testing() + }; + assert_eq!(config.archive_config().unwrap().read_only, false); + + // Explicit false without archive mode + let config = Config { + archive_read_only: Some(false), + ..Config::for_testing() + }; + assert_eq!(config.archive_config().unwrap().read_only, false); + } + + #[test] + fn test_archive_read_only_error() { + // Error: true without archive mode + let config = Config { + archive_read_only: Some(true), + ..Config::for_testing() + }; + assert!(config.archive_config().is_err()); + assert!(config + .archive_config() + .unwrap_err() + .to_string() + .contains("requires either")); + } } diff --git a/src/http/nip11.rs b/src/http/nip11.rs index b756d9c..71cadb1 100644 --- a/src/http/nip11.rs +++ b/src/http/nip11.rs @@ -56,6 +56,41 @@ pub struct RelayInformationDocument { impl RelayInformationDocument { /// Create NIP-11 relay information document from configuration pub fn from_config(config: &Config) -> Self { + // Determine if archive mode is enabled + let archive_config = config.archive_config().ok(); + let archive_enabled = archive_config + .as_ref() + .map(|ac| ac.enabled()) + .unwrap_or(false); + let archive_read_only = archive_config + .as_ref() + .map(|ac| ac.read_only) + .unwrap_or(false); + + // 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 + let curation = if archive_read_only { + if let Some(ref ac) = archive_config { + if ac.archive_all { + Some("Read-only sync of all repositories found on network".to_string()) + } else if !ac.whitelist.is_empty() { + Some("Read-only sync of whitelisted repositories and maintainers".to_string()) + } else { + None + } + } else { + None + } + } else { + None + }; + Self { name: config.relay_name(), description: config.relay_description.clone(), @@ -75,9 +110,9 @@ impl RelayInformationDocument { icon: Some(format!("https://{}/icon.png", config.domain)), // GRASP Extensions - supported_grasps: vec!["GRASP-01".to_string(), "GRASP-02".to_string()], + supported_grasps, repo_acceptance_criteria: "None".to_string(), - curation: None, // Not a curated relay - only SPAM prevention via GRASP-01 policy + curation, } } @@ -90,6 +125,7 @@ impl RelayInformationDocument { #[cfg(test)] mod tests { use super::*; + use nostr_sdk::nips::nip19::ToBech32; #[test] fn test_relay_information_document_structure() { @@ -112,6 +148,7 @@ mod tests { 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()); @@ -147,4 +184,50 @@ mod tests { 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")); + } } diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index deee641..33f2fe5 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -575,9 +575,10 @@ pub async fn create_relay( if archive_config.enabled() { tracing::info!( - "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}", + "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}", archive_config.archive_all, - archive_config.whitelist.len() + archive_config.whitelist.len(), + archive_config.read_only ); } diff --git a/src/nostr/events.rs b/src/nostr/events.rs index dabe5fe..f83e00c 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -362,10 +362,14 @@ impl RepositoryState { /// Validate a repository announcement according to GRASP-01 and GRASP-05 /// /// Returns: -/// - Accept: Announcement lists our service (GRASP-01) +/// - Accept: Announcement lists our service (GRASP-01) - unless archive_read_only mode /// - AcceptArchive: Announcement matches archive config (GRASP-05) /// - Reject: Validation failed /// +/// When archive_read_only is true: +/// - ONLY accept announcements matching archive whitelist/all +/// - REJECT announcements listing our service but not in whitelist (read-only sync mode) +/// /// Note: AcceptMaintainer is NOT returned here (requires database access) pub fn validate_announcement( event: &Event, @@ -394,23 +398,32 @@ pub fn validate_announcement( Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), }; - // GRASP-01: Check if announcement lists our service - if announcement.lists_service(domain) { + // GRASP-01: Normal mode - accept if announcement lists our service + if announcement.lists_service(domain) && !archive_config.read_only { return AnnouncementResult::Accept; } - // GRASP-05: Check if announcement matches archive configuration let npub = announcement.owner_npub(); + + // GRASP-05: Archive mode - accept if announcement matches whitelist if archive_config.matches(&npub, &announcement.identifier) { return AnnouncementResult::AcceptArchive; } - // Reject: Doesn't list us and not whitelisted - AnnouncementResult::Reject(format!( - "Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \ - Found clone URLs: {:?}, relays: {:?}", - announcement.clone_urls, announcement.relays - )) + // Reject with appropriate error message + if archive_config.read_only { + AnnouncementResult::Reject(format!( + "Archive read-only mode: announcement must match archive whitelist. \ + Repository {}/{} not in whitelist", + npub, announcement.identifier + )) + } else { + AnnouncementResult::Reject(format!( + "Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \ + Found clone URLs: {:?}, relays: {:?}", + announcement.clone_urls, announcement.relays + )) + } } /// Validate a repository state announcement according to GRASP-01 @@ -969,6 +982,7 @@ mod tests { let archive_config = ArchiveConfig { archive_all: false, whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], + read_only: false, }; let result = validate_announcement(&event, "gitnostr.com", &archive_config); @@ -994,6 +1008,7 @@ mod tests { let archive_config = ArchiveConfig { archive_all: false, whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], + read_only: false, }; let result = validate_announcement(&event, "gitnostr.com", &archive_config); @@ -1023,6 +1038,7 @@ mod tests { npub, identifier: "linux".into(), }], + read_only: false, }; let result = validate_announcement(&event, "gitnostr.com", &archive_config); @@ -1048,6 +1064,7 @@ mod tests { let archive_config = ArchiveConfig { archive_all: true, whitelist: Vec::new(), + read_only: false, }; let result = validate_announcement(&event, "gitnostr.com", &archive_config); @@ -1073,6 +1090,7 @@ mod tests { let archive_config = ArchiveConfig { archive_all: false, whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], + read_only: false, }; let result = validate_announcement(&event, "gitnostr.com", &archive_config); @@ -1094,13 +1112,96 @@ mod tests { vec!["wss://gitnostr.com"], ); - // Even with archive config, GRASP-01 Accept takes precedence + // With archive_read_only=false, GRASP-01 Accept takes precedence let archive_config = ArchiveConfig { archive_all: true, whitelist: Vec::new(), + read_only: false, }; let result = validate_announcement(&event, "gitnostr.com", &archive_config); assert!(matches!(result, AnnouncementResult::Accept)); } + + #[test] + fn test_archive_read_only_rejects_non_whitelisted() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that DOES list our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // With archive_read_only=true and whitelist that doesn't include this repo, + // should reject even though it lists our service + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], + read_only: true, + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::Reject(_))); + } + + #[test] + fn test_archive_read_only_accepts_whitelisted() { + use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that lists our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // With archive_read_only=true and whitelist that DOES include this repo, + // should accept as AcceptArchive + let archive_config = ArchiveConfig { + archive_all: false, + whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], + read_only: true, + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } + + #[test] + fn test_archive_read_only_with_archive_all() { + use crate::config::ArchiveConfig; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that lists our service + let event = create_announcement_event( + &keys, + "any-repo", + vec!["https://gitnostr.com/alice/any-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // With archive_read_only=true and archive_all=true, + // should accept as AcceptArchive + let archive_config = ArchiveConfig { + archive_all: true, + whitelist: Vec::new(), + read_only: true, + }; + + let result = validate_announcement(&event, "gitnostr.com", &archive_config); + assert!(matches!(result, AnnouncementResult::AcceptArchive)); + } } -- cgit v1.2.3