diff --git a/.env.example b/.env.example index 2dc5266..cb797a8 100644 --- a/.env.example +++ b/.env.example @@ -189,4 +189,17 @@ # NGIT_ARCHIVE_WHITELIST=npub1alice... # NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob.../linux # NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust -# NGIT_ARCHIVE_WHITELIST= \ No newline at end of file +# NGIT_ARCHIVE_WHITELIST= + +# Archive read-only mode (relay is read-only sync of archived repositories) +# When true: +# - NIP-11 includes GRASP-05 in supported_grasps +# - NIP-11 curation field describes archive scope +# - Repository announcements not listing this service are accepted per whitelist/archive-all +# When false: +# - Archive mode disabled (standard GRASP-01 operation) +# +# CLI: --archive-read-only +# Default: true if NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set, false otherwise +# Note: Setting to true without archive config causes startup error +# NGIT_ARCHIVE_READ_ONLY= \ No newline at end of file diff --git a/README.md b/README.md index 6de9d89..ba6dfa2 100644 --- a/README.md +++ b/README.md @@ -141,11 +141,11 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f - ✅ Accept repositories not listing this instance via configurable whitelist - ✅ Three whitelist formats: ``, `/`, `` -- ✅ Read-only mirroring with full GRASP-02 sync (git data + Nostr events) +- ✅ Read-only mirroring with full GRASP-02 sync (git data + Nostr events) - **default behavior** - ✅ Archive-all mode for complete ecosystem mirrors - ✅ Fail-fast npub validation at startup -**Archive mode enables backup/mirror operation** - accept repository announcements that don't list your relay, useful for creating archives of critical projects or running comprehensive mirrors. Archived repositories are read-only with full event and git data sync. +**Archive mode enables backup/mirror operation** - accept repository announcements that don't list your relay, useful for creating archives of critical projects or running comprehensive mirrors. Archived repositories are read-only by default (`NGIT_ARCHIVE_READ_ONLY=true`) with full event and git data sync. **See**: [GRASP-05 Archive Mode](docs/explanation/grasp-05-archive.md) diff --git a/docs/explanation/grasp-05-archive.md b/docs/explanation/grasp-05-archive.md index e43a87e..45481dd 100644 --- a/docs/explanation/grasp-05-archive.md +++ b/docs/explanation/grasp-05-archive.md @@ -35,14 +35,17 @@ Archive mode relaxes the "must list service" requirement for whitelisted reposit **Configuration:** ```bash -# Specific repos (safest) +# Specific repos (safest) - read-only by default NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin +# NGIT_ARCHIVE_READ_ONLY defaults to true # All repos from trusted maintainers NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob... +# NGIT_ARCHIVE_READ_ONLY defaults to true # Archive everything (⚠️ storage risk) NGIT_ARCHIVE_ALL=true +# NGIT_ARCHIVE_READ_ONLY defaults to true ``` ### Validation Priority @@ -63,11 +66,21 @@ Archived repos use the same directory structure as hosted repos: / npub1alice.../ hosted-repo.git/ # Lists your service (writable) - archived-repo.git/ # Whitelisted (read-only) + archived-repo.git/ # Whitelisted (read-only by default) ``` **No flags or metadata** - archive status determined dynamically from config + announcement contents. +### Read-Only Mode + +By default, archive mode operates in read-only mode (`NGIT_ARCHIVE_READ_ONLY=true`): +- Repository announcements are accepted per whitelist/archive-all configuration +- The service is **not listed** in accepted announcements (passive sync only) +- NIP-11 document advertises `GRASP-05` support +- NIP-11 `curation` field indicates read-only sync scope: + - `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`) + - `"Read-only sync of whitelisted repositories and maintainers"` (if whitelist configured) + ### Full Sync Archived repositories trigger complete GRASP-02 sync: @@ -129,12 +142,14 @@ Watch for: ## Comparison: Hosted vs Archived -| Aspect | Hosted (GRASP-01) | Archived (GRASP-05) | -|--------|-------------------|---------------------| +| Aspect | Hosted (GRASP-01) | Archived (GRASP-05 Read-Only) | +|--------|-------------------|-------------------------------| | Announcement must list you | ✅ Required | ❌ Whitelisted instead | | Git pushes | ✅ Accepted | ❌ Rejected (read-only) | | GRASP-02 sync | ✅ Full sync | ✅ Full sync | -| Relay discovery | ✅ Listed | ❌ Not listed | +| Relay discovery | ✅ Listed in announcements | ❌ Not listed (passive sync) | +| NIP-11 supported_grasps | `["GRASP-01", "GRASP-02"]` | `["GRASP-01", "GRASP-05", "GRASP-02"]` | +| NIP-11 curation field | `null` | Describes archive scope | | Use case | Hosting workspace | Backup/mirror | ## Related Documentation diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 52418ad..4692600 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -574,6 +574,77 @@ NGIT_ARCHIVE_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core --- +#### `NGIT_ARCHIVE_READ_ONLY` + +**Description:** Configure relay as read-only sync of archived repositories +**Type:** Boolean +**Default:** `true` if `NGIT_ARCHIVE_ALL` or `NGIT_ARCHIVE_WHITELIST` is set, `false` otherwise +**Required:** No + +**Examples:** + +```bash +# Explicitly enable (requires archive mode) +NGIT_ARCHIVE_READ_ONLY=true + +# Explicitly disable (writable archive repos) +NGIT_ARCHIVE_READ_ONLY=false + +# Automatic (default behavior) +# - If NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set → true +# - Otherwise → false +# NGIT_ARCHIVE_READ_ONLY= +``` + +**Behavior:** + +- When `true`: + - NIP-11 document includes `GRASP-05` in `supported_grasps` + - NIP-11 `curation` field describes the archive scope + - Repository announcements not listing this service are accepted per whitelist/archive-all +- When `false`: + - Archive mode disabled (standard GRASP-01 operation) +- When unset (default): + - Automatically `true` if archive mode configured + - Automatically `false` otherwise + +**Error Conditions:** + +```bash +# ERROR: Cannot set read-only without archive config +NGIT_ARCHIVE_READ_ONLY=true +NGIT_ARCHIVE_ALL=false +NGIT_ARCHIVE_WHITELIST= +# → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either +# NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" +``` + +**NIP-11 Impact:** + +When `NGIT_ARCHIVE_READ_ONLY=true`: +- `supported_grasps`: includes `"GRASP-05"` +- `curation`: Set to one of: + - `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`) + - `"Read-only sync of whitelisted repositories and maintainers"` (if `NGIT_ARCHIVE_WHITELIST` set) + +**Use Cases:** + +```bash +# Public archive of entire ecosystem +NGIT_ARCHIVE_ALL=true +NGIT_ARCHIVE_READ_ONLY=true # Default + +# Selective backup of critical projects +NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin +NGIT_ARCHIVE_READ_ONLY=true # Default + +# Writable mirror (advanced, not typical) +NGIT_ARCHIVE_WHITELIST=npub1alice... +NGIT_ARCHIVE_READ_ONLY=false +``` + +--- + ### Logging Configuration #### `RUST_LOG` diff --git a/nix/module.nix b/nix/module.nix index f82f069..516fb04 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -196,6 +196,20 @@ let ''; }; + archiveReadOnly = mkOption { + type = types.nullOr types.bool; + default = null; + description = '' + Archive read-only mode (relay is read-only sync of archived repositories). + When true: + - NIP-11 includes GRASP-05 in supported_grasps + - NIP-11 curation field describes archive scope + - Repository announcements not listing this service are accepted per whitelist/archive-all + Default: true if archiveAll or archiveWhitelist is set, false otherwise + Note: Setting to true without archive config causes startup error + ''; + }; + user = mkOption { type = types.str; default = "ngit-grasp-${name}"; @@ -241,6 +255,8 @@ let RUST_LOG = cfg.logLevel; } // optionalAttrs (cfg.relayName != null) { NGIT_RELAY_NAME = cfg.relayName; + } // optionalAttrs (cfg.archiveReadOnly != null) { + NGIT_ARCHIVE_READ_ONLY = toString cfg.archiveReadOnly; } // optionalAttrs cfg.metricsEnabled { NGIT_METRICS_ENABLED = "true"; } // optionalAttrs (cfg.syncBootstrapRelayUrl != null) { NGIT_SYNC_BOOTSTRAP_RELAY_URL = cfg.syncBootstrapRelayUrl; 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)); + } }