upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 17:40:25 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 17:40:25 +0000
commitc29191b1e1239e931c575a926ec9480e594476d6 (patch)
tree6fcb776ba34b6fab766ceb613997b07b18e780df /src
parent2b8992631b9dedcfd4ea44e8565b14ac8a5ed8ea (diff)
feat(grasp-05): implement archive mode for backup/mirror operation
Implements GRASP-05 specification for accepting repository announcements that don't list this relay, enabling archive, mirror, and backup use cases. Core Features: - Three whitelist formats: <npub>, <npub>/<identifier>, <identifier> - Archive-all mode for complete ecosystem mirrors - Fail-fast npub validation at startup - Read-only enforcement (archived repos reject pushes) - Full GRASP-02 sync (git data + Nostr events) - Dynamic archive status (no flags/metadata) Implementation: - Add ArchiveWhitelistEntry enum with Pubkey/Repository/Identifier variants - Add ArchiveConfig with validation and matching logic - Update AnnouncementResult to include AcceptArchive variant - Refactor validate_announcement() to return AnnouncementResult with archive check - Update AnnouncementPolicy with catch-all pattern for cleaner code - Wire archive config through builder and policy layers Configuration: - NGIT_ARCHIVE_ALL: Accept all announcements (⚠️ storage risk) - NGIT_ARCHIVE_WHITELIST: Comma-separated whitelist entries - Updated docs, .env.example, and nix/module.nix Testing: - 28 unit tests for config parsing and whitelist matching - 7 integration tests for archive mode validation - All 296 tests passing Validation Priority: 1. Lists our service → Accept (GRASP-01, read/write) 2. Is maintainer → AcceptMaintainer (multi-maintainer, read/write) 3. Matches archive config → AcceptArchive (GRASP-05, read-only) 4. None of above → Reject Security Considerations: - Archive-all mode has storage/bandwidth DoS risk - Identifier-only format matches any pubkey (use npub/identifier for high-value) - Invalid npubs cause startup failure (fail-fast) Documentation: - Concise explanation focused on rationale - Reference docs updated with all config options - README updated to reflect completed feature - Removed from roadmap, added to compliance section See docs/explanation/grasp-05-archive.md for details.
Diffstat (limited to 'src')
-rw-r--r--src/config.rs321
-rw-r--r--src/nostr/builder.rs53
-rw-r--r--src/nostr/events.rs277
-rw-r--r--src/nostr/policy/announcement.rs42
4 files changed, 637 insertions, 56 deletions
diff --git a/src/config.rs b/src/config.rs
index 1812fe2..b1ab43e 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,10 +1,150 @@
1use anyhow::{Context, Result}; 1use anyhow::{anyhow, Context, Result};
2use clap::{Parser, ValueEnum}; 2use clap::{Parser, ValueEnum};
3use nostr_sdk::prelude::*; 3use nostr_sdk::prelude::*;
4use serde::{Deserialize, Serialize}; 4use serde::{Deserialize, Serialize};
5use std::fs; 5use std::fs;
6use std::path::PathBuf; 6use std::path::PathBuf;
7 7
8/// GRASP-05 Archive whitelist entry
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "lowercase")]
11pub enum ArchiveWhitelistEntry {
12 /// Archive all repos from this pubkey: "npub1..."
13 Pubkey(String),
14
15 /// Archive specific repo: "npub1.../identifier"
16 Repository { npub: String, identifier: String },
17
18 /// Archive any repo with this identifier: "identifier"
19 Identifier(String),
20}
21
22impl ArchiveWhitelistEntry {
23 /// Parse a whitelist entry from string
24 ///
25 /// Formats:
26 /// - "npub1..." -> Pubkey
27 /// - "npub1.../identifier" -> Repository
28 /// - "identifier" -> Identifier
29 ///
30 /// Validates npub format at parse time (fail fast)
31 pub fn parse(s: &str) -> Result<Self> {
32 let trimmed = s.trim();
33
34 if trimmed.contains('/') {
35 // Format: npub1.../identifier
36 let parts: Vec<&str> = trimmed.split('/').collect();
37 if parts.len() != 2 {
38 return Err(anyhow!(
39 "Invalid whitelist entry format '{}'. Expected 'npub/identifier'",
40 s
41 ));
42 }
43
44 let npub = parts[0];
45 let identifier = parts[1];
46
47 // Validate npub format (fail fast)
48 if !npub.starts_with("npub1") {
49 return Err(anyhow!(
50 "Invalid whitelist entry '{}'. First part must be npub",
51 s
52 ));
53 }
54
55 PublicKey::from_bech32(npub)
56 .context(format!("Invalid npub in whitelist entry '{}'", s))?;
57
58 Ok(Self::Repository {
59 npub: npub.to_string(),
60 identifier: identifier.to_string(),
61 })
62 } else if trimmed.starts_with("npub1") {
63 // Format: npub1...
64 // Validate npub format (fail fast)
65 PublicKey::from_bech32(trimmed)
66 .context(format!("Invalid npub in whitelist entry '{}'", s))?;
67
68 Ok(Self::Pubkey(trimmed.to_string()))
69 } else {
70 // Format: identifier
71 Ok(Self::Identifier(trimmed.to_string()))
72 }
73 }
74
75 /// Check if this entry matches the given npub and identifier
76 pub fn matches(&self, npub: &str, identifier: &str) -> bool {
77 match self {
78 Self::Pubkey(p) => npub == p,
79 Self::Repository {
80 npub: p,
81 identifier: i,
82 } => npub == p && identifier == i,
83 Self::Identifier(i) => identifier == i,
84 }
85 }
86}
87
88/// GRASP-05 Archive mode configuration
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ArchiveConfig {
91 /// Accept all repository announcements (no filtering)
92 ///
93 /// WARNING: Setting this to true allows anyone to mirror any repository
94 /// to this relay, potentially causing storage/bandwidth exhaustion.
95 pub archive_all: bool,
96
97 /// Whitelist entries for selective archiving
98 ///
99 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
100 pub whitelist: Vec<ArchiveWhitelistEntry>,
101}
102
103impl ArchiveConfig {
104 /// Check if GRASP-05 is enabled (either archive_all or non-empty whitelist)
105 pub fn enabled(&self) -> bool {
106 self.archive_all || !self.whitelist.is_empty()
107 }
108
109 /// Check if an announcement matches the archive configuration
110 ///
111 /// Returns true if:
112 /// - archive_all is true, OR
113 /// - announcement matches any whitelist entry
114 pub fn matches(&self, npub: &str, identifier: &str) -> bool {
115 if self.archive_all {
116 return true;
117 }
118
119 self.whitelist
120 .iter()
121 .any(|entry| entry.matches(npub, identifier))
122 }
123
124 /// Parse archive whitelist from comma-separated string
125 pub fn parse_whitelist(input: &str) -> Result<Vec<ArchiveWhitelistEntry>> {
126 if input.trim().is_empty() {
127 return Ok(Vec::new());
128 }
129
130 input
131 .split(',')
132 .map(|s| s.trim())
133 .filter(|s| !s.is_empty())
134 .map(ArchiveWhitelistEntry::parse)
135 .collect()
136 }
137}
138
139impl Default for ArchiveConfig {
140 fn default() -> Self {
141 Self {
142 archive_all: false,
143 whitelist: Vec::new(),
144 }
145 }
146}
147
8/// Database backend type for the relay 148/// Database backend type for the relay
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] 149#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)]
10#[serde(rename_all = "lowercase")] 150#[serde(rename_all = "lowercase")]
@@ -162,6 +302,15 @@ pub struct Config {
162 /// tracked separately and retried after this expiration period. 302 /// tracked separately and retried after this expiration period.
163 #[arg(long, env = "NGIT_NAUGHTY_LIST_EXPIRATION_HOURS", default_value_t = 12)] 303 #[arg(long, env = "NGIT_NAUGHTY_LIST_EXPIRATION_HOURS", default_value_t = 12)]
164 pub naughty_list_expiration_hours: u64, 304 pub naughty_list_expiration_hours: u64,
305
306 /// Enable GRASP-05 archive mode: accept all announcements regardless of listing (WARNING: storage risk)
307 #[arg(long, env = "NGIT_ARCHIVE_ALL", default_value_t = false)]
308 pub archive_all: bool,
309
310 /// GRASP-05 archive whitelist: comma-separated list of npub/identifier/npub/identifier entries
311 /// Formats: "npub1...", "npub1.../identifier", "identifier"
312 #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")]
313 pub archive_whitelist: String,
165} 314}
166 315
167impl Config { 316impl Config {
@@ -262,6 +411,15 @@ impl Config {
262 } 411 }
263 } 412 }
264 413
414 /// Get parsed archive configuration
415 pub fn archive_config(&self) -> Result<ArchiveConfig> {
416 let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?;
417 Ok(ArchiveConfig {
418 archive_all: self.archive_all,
419 whitelist,
420 })
421 }
422
265 /// Create config for testing 423 /// Create config for testing
266 #[cfg(test)] 424 #[cfg(test)]
267 pub fn for_testing() -> Self { 425 pub fn for_testing() -> Self {
@@ -292,6 +450,8 @@ impl Config {
292 rejected_hot_cache_duration_secs: 120, 450 rejected_hot_cache_duration_secs: 120,
293 rejected_cold_index_expiry_secs: 604800, 451 rejected_cold_index_expiry_secs: 604800,
294 naughty_list_expiration_hours: 12, 452 naughty_list_expiration_hours: 12,
453 archive_all: false,
454 archive_whitelist: String::new(),
295 } 455 }
296 } 456 }
297} 457}
@@ -426,4 +586,163 @@ mod tests {
426 assert_eq!(config.metrics_connection_per_ip_abuse_threshold, 50); 586 assert_eq!(config.metrics_connection_per_ip_abuse_threshold, 50);
427 assert_eq!(config.metrics_top_n_repos, 25); 587 assert_eq!(config.metrics_top_n_repos, 25);
428 } 588 }
589
590 #[test]
591 fn test_parse_whitelist_entry_pubkey() {
592 // Generate a valid test npub
593 let keys = Keys::generate();
594 let test_npub = keys.public_key().to_bech32().unwrap();
595 let entry = ArchiveWhitelistEntry::parse(&test_npub).unwrap();
596 assert!(matches!(entry, ArchiveWhitelistEntry::Pubkey(_)));
597 if let ArchiveWhitelistEntry::Pubkey(npub) = entry {
598 assert_eq!(npub, test_npub);
599 }
600 }
601
602 #[test]
603 fn test_parse_whitelist_entry_repository() {
604 let keys = Keys::generate();
605 let test_npub = keys.public_key().to_bech32().unwrap();
606 let entry = ArchiveWhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap();
607 assert!(matches!(entry, ArchiveWhitelistEntry::Repository { .. }));
608 if let ArchiveWhitelistEntry::Repository { npub, identifier } = entry {
609 assert_eq!(npub, test_npub);
610 assert_eq!(identifier, "linux");
611 }
612 }
613
614 #[test]
615 fn test_parse_whitelist_entry_identifier() {
616 let entry = ArchiveWhitelistEntry::parse("bitcoin-core").unwrap();
617 assert!(matches!(entry, ArchiveWhitelistEntry::Identifier(_)));
618 if let ArchiveWhitelistEntry::Identifier(id) = entry {
619 assert_eq!(id, "bitcoin-core");
620 }
621 }
622
623 #[test]
624 fn test_parse_whitelist_entry_invalid_npub() {
625 let result = ArchiveWhitelistEntry::parse("npub1invalid");
626 assert!(result.is_err());
627 }
628
629 #[test]
630 fn test_whitelist_entry_matches() {
631 let keys = Keys::generate();
632 let test_npub = keys.public_key().to_bech32().unwrap();
633 let entry = ArchiveWhitelistEntry::Pubkey(test_npub.clone());
634 assert!(entry.matches(&test_npub, "any-identifier"));
635 assert!(!entry.matches("npub1different", "any-identifier"));
636 }
637
638 #[test]
639 fn test_whitelist_entry_matches_repository() {
640 let keys = Keys::generate();
641 let test_npub = keys.public_key().to_bech32().unwrap();
642 let entry = ArchiveWhitelistEntry::Repository {
643 npub: test_npub.clone(),
644 identifier: "linux".to_string(),
645 };
646 assert!(entry.matches(&test_npub, "linux"));
647 assert!(!entry.matches(&test_npub, "bitcoin"));
648 assert!(!entry.matches("npub1different", "linux"));
649 }
650
651 #[test]
652 fn test_whitelist_entry_matches_identifier() {
653 let entry = ArchiveWhitelistEntry::Identifier("bitcoin-core".to_string());
654 assert!(entry.matches("npub1alice", "bitcoin-core"));
655 assert!(entry.matches("npub1bob", "bitcoin-core"));
656 assert!(!entry.matches("npub1alice", "other-repo"));
657 }
658
659 #[test]
660 fn test_archive_config_enabled() {
661 let config = ArchiveConfig::default();
662 assert!(!config.enabled());
663
664 let config = ArchiveConfig {
665 archive_all: true,
666 whitelist: Vec::new(),
667 };
668 assert!(config.enabled());
669
670 let config = ArchiveConfig {
671 archive_all: false,
672 whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())],
673 };
674 assert!(config.enabled());
675 }
676
677 #[test]
678 fn test_archive_config_matches() {
679 let keys = Keys::generate();
680 let test_npub = keys.public_key().to_bech32().unwrap();
681 let config = ArchiveConfig {
682 archive_all: false,
683 whitelist: vec![
684 ArchiveWhitelistEntry::Pubkey(test_npub.clone()),
685 ArchiveWhitelistEntry::Identifier("bitcoin-core".into()),
686 ],
687 };
688
689 assert!(config.matches(&test_npub, "any-repo"));
690 assert!(config.matches("npub1bob", "bitcoin-core"));
691 assert!(!config.matches("npub1bob", "other-repo"));
692 }
693
694 #[test]
695 fn test_archive_config_matches_archive_all() {
696 let config = ArchiveConfig {
697 archive_all: true,
698 whitelist: Vec::new(),
699 };
700
701 assert!(config.matches("npub1alice", "any-repo"));
702 assert!(config.matches("npub1bob", "other-repo"));
703 }
704
705 #[test]
706 fn test_parse_whitelist_empty() {
707 let whitelist = ArchiveConfig::parse_whitelist("").unwrap();
708 assert!(whitelist.is_empty());
709
710 let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap();
711 assert!(whitelist.is_empty());
712 }
713
714 #[test]
715 fn test_parse_whitelist_multiple() {
716 let keys1 = Keys::generate();
717 let keys2 = Keys::generate();
718 let test_npub1 = keys1.public_key().to_bech32().unwrap();
719 let test_npub2 = keys2.public_key().to_bech32().unwrap();
720 let whitelist = ArchiveConfig::parse_whitelist(&format!(
721 "{},bitcoin-core,{}/linux",
722 test_npub1, test_npub2
723 ))
724 .unwrap();
725 assert_eq!(whitelist.len(), 3);
726 }
727
728 #[test]
729 fn test_archive_config_parsing() {
730 let keys = Keys::generate();
731 let test_npub = keys.public_key().to_bech32().unwrap();
732 let config = Config {
733 archive_whitelist: format!("{},bitcoin-core", test_npub),
734 ..Config::for_testing()
735 };
736 let archive_config = config.archive_config().unwrap();
737 assert_eq!(archive_config.whitelist.len(), 2);
738 }
739
740 #[test]
741 fn test_archive_config_invalid_npub() {
742 let config = Config {
743 archive_whitelist: "npub1invalid".to_string(),
744 ..Config::for_testing()
745 };
746 assert!(config.archive_config().is_err());
747 }
429} 748}
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index c010854..deee641 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -55,10 +55,11 @@ impl Nip34WritePolicy {
55 database: SharedDatabase, 55 database: SharedDatabase,
56 git_data_path: impl Into<std::path::PathBuf>, 56 git_data_path: impl Into<std::path::PathBuf>,
57 purgatory: std::sync::Arc<crate::purgatory::Purgatory>, 57 purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
58 archive_config: crate::config::ArchiveConfig,
58 ) -> Self { 59 ) -> Self {
59 let ctx = PolicyContext::new(domain, database, git_data_path, purgatory); 60 let ctx = PolicyContext::new(domain, database, git_data_path, purgatory);
60 Self { 61 Self {
61 announcement_policy: AnnouncementPolicy::new(ctx.clone()), 62 announcement_policy: AnnouncementPolicy::new(ctx.clone(), archive_config),
62 state_policy: StatePolicy::new(ctx.clone()), 63 state_policy: StatePolicy::new(ctx.clone()),
63 pr_event_policy: PrEventPolicy::new(ctx.clone()), 64 pr_event_policy: PrEventPolicy::new(ctx.clone()),
64 related_event_policy: RelatedEventPolicy::new(ctx.clone()), 65 related_event_policy: RelatedEventPolicy::new(ctx.clone()),
@@ -147,6 +148,34 @@ impl Nip34WritePolicy {
147 } 148 }
148 } 149 }
149 } 150 }
151 AnnouncementResult::AcceptArchive => {
152 // GRASP-05: Archive mode - accept announcement but don't create bare repository
153 match RepositoryAnnouncement::from_event(event.clone()) {
154 Ok(announcement) => {
155 tracing::info!(
156 "Accepted archive announcement {} for {}/{} (GRASP-05 read-only mirror)",
157 event_id_str,
158 announcement.owner_npub(),
159 announcement.identifier
160 );
161 // Don't create bare repository for archived announcements
162
163 // Check purgatory for state events that might now be authorized
164 self.check_purgatory_state_events_for_identifier(&announcement.identifier)
165 .await;
166
167 WritePolicyResult::Accept
168 }
169 Err(e) => {
170 tracing::warn!(
171 "Failed to parse archive announcement {}: {}",
172 event_id_str,
173 e
174 );
175 WritePolicyResult::reject(format!("Failed to parse announcement: {}", e))
176 }
177 }
178 }
150 AnnouncementResult::Reject(reason) => { 179 AnnouncementResult::Reject(reason) => {
151 tracing::warn!( 180 tracing::warn!(
152 "Rejected repository announcement {}: {}", 181 "Rejected repository announcement {}: {}",
@@ -539,9 +568,27 @@ pub async fn create_relay(
539 // Clone Arc for the write policy so both relay and policy can access the database 568 // Clone Arc for the write policy so both relay and policy can access the database
540 let git_data_path = config.effective_git_data_path(); 569 let git_data_path = config.effective_git_data_path();
541 570
571 // Parse archive configuration
572 let archive_config = config
573 .archive_config()
574 .map_err(|e| anyhow::anyhow!("Failed to parse archive configuration: {}", e))?;
575
576 if archive_config.enabled() {
577 tracing::info!(
578 "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}",
579 archive_config.archive_all,
580 archive_config.whitelist.len()
581 );
582 }
583
542 // Create write policy with purgatory integration 584 // Create write policy with purgatory integration
543 let write_policy = 585 let write_policy = Nip34WritePolicy::new(
544 Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path, purgatory); 586 &config.domain,
587 database.clone(),
588 &git_data_path,
589 purgatory,
590 archive_config,
591 );
545 592
546 let relay = LocalRelayBuilder::default() 593 let relay = LocalRelayBuilder::default()
547 .database(database.clone()) 594 .database(database.clone())
diff --git a/src/nostr/events.rs b/src/nostr/events.rs
index 9d43ca3..dabe5fe 100644
--- a/src/nostr/events.rs
+++ b/src/nostr/events.rs
@@ -359,13 +359,24 @@ impl RepositoryState {
359 } 359 }
360} 360}
361 361
362/// Validate a repository announcement according to GRASP-01 362/// Validate a repository announcement according to GRASP-01 and GRASP-05
363/// 363///
364/// Returns Ok(()) if valid, Err with reason if invalid. 364/// Returns:
365pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> { 365/// - Accept: Announcement lists our service (GRASP-01)
366/// - AcceptArchive: Announcement matches archive config (GRASP-05)
367/// - Reject: Validation failed
368///
369/// Note: AcceptMaintainer is NOT returned here (requires database access)
370pub fn validate_announcement(
371 event: &Event,
372 domain: &str,
373 archive_config: &crate::config::ArchiveConfig,
374) -> crate::nostr::policy::AnnouncementResult {
375 use crate::nostr::policy::AnnouncementResult;
376
366 // Must be kind 30617 377 // Must be kind 30617
367 if event.kind != Kind::GitRepoAnnouncement { 378 if event.kind != Kind::GitRepoAnnouncement {
368 return Err(anyhow!( 379 return AnnouncementResult::Reject(format!(
369 "Invalid kind: expected {}", 380 "Invalid kind: expected {}",
370 Kind::GitRepoAnnouncement 381 Kind::GitRepoAnnouncement
371 )); 382 ));
@@ -374,24 +385,32 @@ pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> {
374 // Must have identifier 385 // Must have identifier
375 let has_identifier = event.tags.iter().any(|t| t.kind() == TagKind::d()); 386 let has_identifier = event.tags.iter().any(|t| t.kind() == TagKind::d());
376 if !has_identifier { 387 if !has_identifier {
377 return Err(anyhow!("Missing required 'd' tag (identifier)")); 388 return AnnouncementResult::Reject("Missing required 'd' tag (identifier)".to_string());
378 } 389 }
379 390
380 // Parse full announcement to validate structure 391 // Parse full announcement to validate structure
381 let announcement = RepositoryAnnouncement::from_event(event.clone())?; 392 let announcement = match RepositoryAnnouncement::from_event(event.clone()) {
382 393 Ok(a) => a,
383 // GRASP-01: MUST reject announcements that do not list the service 394 Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)),
384 // in both `clone` and `relays` tags unless implementing GRASP-05 395 };
385 if !announcement.lists_service(domain) { 396
386 return Err(anyhow!( 397 // GRASP-01: Check if announcement lists our service
387 "Announcement must list service in both 'clone' and 'relays' tags. \ 398 if announcement.lists_service(domain) {
388 Found clone URLs: {:?}, relays: {:?}", 399 return AnnouncementResult::Accept;
389 announcement.clone_urls,
390 announcement.relays
391 ));
392 } 400 }
393 401
394 Ok(()) 402 // GRASP-05: Check if announcement matches archive configuration
403 let npub = announcement.owner_npub();
404 if archive_config.matches(&npub, &announcement.identifier) {
405 return AnnouncementResult::AcceptArchive;
406 }
407
408 // Reject: Doesn't list us and not whitelisted
409 AnnouncementResult::Reject(format!(
410 "Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \
411 Found clone URLs: {:?}, relays: {:?}",
412 announcement.clone_urls, announcement.relays
413 ))
395} 414}
396 415
397/// Validate a repository state announcement according to GRASP-01 416/// Validate a repository state announcement according to GRASP-01
@@ -529,6 +548,9 @@ mod tests {
529 548
530 #[test] 549 #[test]
531 fn test_validate_announcement_success() { 550 fn test_validate_announcement_success() {
551 use crate::config::ArchiveConfig;
552 use crate::nostr::policy::AnnouncementResult;
553
532 let keys = create_test_keys(); 554 let keys = create_test_keys();
533 let event = create_announcement_event( 555 let event = create_announcement_event(
534 &keys, 556 &keys,
@@ -537,12 +559,15 @@ mod tests {
537 vec!["wss://gitnostr.com"], 559 vec!["wss://gitnostr.com"],
538 ); 560 );
539 561
540 let result = validate_announcement(&event, "gitnostr.com"); 562 let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
541 assert!(result.is_ok()); 563 assert!(matches!(result, AnnouncementResult::Accept));
542 } 564 }
543 565
544 #[test] 566 #[test]
545 fn test_validate_announcement_missing_clone() { 567 fn test_validate_announcement_missing_clone() {
568 use crate::config::ArchiveConfig;
569 use crate::nostr::policy::AnnouncementResult;
570
546 let keys = create_test_keys(); 571 let keys = create_test_keys();
547 let event = create_announcement_event( 572 let event = create_announcement_event(
548 &keys, 573 &keys,
@@ -551,13 +576,19 @@ mod tests {
551 vec!["wss://gitnostr.com"], 576 vec!["wss://gitnostr.com"],
552 ); 577 );
553 578
554 let result = validate_announcement(&event, "gitnostr.com"); 579 let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
555 assert!(result.is_err()); 580 if let AnnouncementResult::Reject(reason) = result {
556 assert!(result.unwrap_err().to_string().contains("clone")); 581 assert!(reason.contains("clone"));
582 } else {
583 panic!("Expected Reject, got {:?}", result);
584 }
557 } 585 }
558 586
559 #[test] 587 #[test]
560 fn test_validate_announcement_missing_relay() { 588 fn test_validate_announcement_missing_relay() {
589 use crate::config::ArchiveConfig;
590 use crate::nostr::policy::AnnouncementResult;
591
561 let keys = create_test_keys(); 592 let keys = create_test_keys();
562 let event = create_announcement_event( 593 let event = create_announcement_event(
563 &keys, 594 &keys,
@@ -566,13 +597,19 @@ mod tests {
566 vec![], // No relays 597 vec![], // No relays
567 ); 598 );
568 599
569 let result = validate_announcement(&event, "gitnostr.com"); 600 let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
570 assert!(result.is_err()); 601 if let AnnouncementResult::Reject(reason) = result {
571 assert!(result.unwrap_err().to_string().contains("relays")); 602 assert!(reason.contains("relays"));
603 } else {
604 panic!("Expected Reject, got {:?}", result);
605 }
572 } 606 }
573 607
574 #[test] 608 #[test]
575 fn test_validate_announcement_wrong_domain() { 609 fn test_validate_announcement_wrong_domain() {
610 use crate::config::ArchiveConfig;
611 use crate::nostr::policy::AnnouncementResult;
612
576 let keys = create_test_keys(); 613 let keys = create_test_keys();
577 let event = create_announcement_event( 614 let event = create_announcement_event(
578 &keys, 615 &keys,
@@ -581,8 +618,8 @@ mod tests {
581 vec!["wss://other-service.com"], 618 vec!["wss://other-service.com"],
582 ); 619 );
583 620
584 let result = validate_announcement(&event, "gitnostr.com"); 621 let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
585 assert!(result.is_err()); 622 assert!(matches!(result, AnnouncementResult::Reject(_)));
586 } 623 }
587 624
588 #[test] 625 #[test]
@@ -805,6 +842,9 @@ mod tests {
805 842
806 #[test] 843 #[test]
807 fn test_validate_announcement_with_trailing_slash_in_relay() { 844 fn test_validate_announcement_with_trailing_slash_in_relay() {
845 use crate::config::ArchiveConfig;
846 use crate::nostr::policy::AnnouncementResult;
847
808 let keys = create_test_keys(); 848 let keys = create_test_keys();
809 let event = create_announcement_event( 849 let event = create_announcement_event(
810 &keys, 850 &keys,
@@ -814,12 +854,16 @@ mod tests {
814 ); 854 );
815 855
816 // Should accept despite trailing slash mismatch 856 // Should accept despite trailing slash mismatch
817 let result = validate_announcement(&event, "git.shakespeare.diy"); 857 let result =
818 assert!(result.is_ok()); 858 validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
859 assert!(matches!(result, AnnouncementResult::Accept));
819 } 860 }
820 861
821 #[test] 862 #[test]
822 fn test_validate_announcement_with_trailing_slash_in_clone_url() { 863 fn test_validate_announcement_with_trailing_slash_in_clone_url() {
864 use crate::config::ArchiveConfig;
865 use crate::nostr::policy::AnnouncementResult;
866
823 let keys = create_test_keys(); 867 let keys = create_test_keys();
824 let event = create_announcement_event( 868 let event = create_announcement_event(
825 &keys, 869 &keys,
@@ -829,12 +873,16 @@ mod tests {
829 ); 873 );
830 874
831 // Should accept despite trailing slash mismatch 875 // Should accept despite trailing slash mismatch
832 let result = validate_announcement(&event, "git.shakespeare.diy"); 876 let result =
833 assert!(result.is_ok()); 877 validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
878 assert!(matches!(result, AnnouncementResult::Accept));
834 } 879 }
835 880
836 #[test] 881 #[test]
837 fn test_validate_announcement_with_trailing_slash_in_both() { 882 fn test_validate_announcement_with_trailing_slash_in_both() {
883 use crate::config::ArchiveConfig;
884 use crate::nostr::policy::AnnouncementResult;
885
838 let keys = create_test_keys(); 886 let keys = create_test_keys();
839 let event = create_announcement_event( 887 let event = create_announcement_event(
840 &keys, 888 &keys,
@@ -844,12 +892,16 @@ mod tests {
844 ); 892 );
845 893
846 // Should accept with trailing slashes in both 894 // Should accept with trailing slashes in both
847 let result = validate_announcement(&event, "git.shakespeare.diy"); 895 let result =
848 assert!(result.is_ok()); 896 validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
897 assert!(matches!(result, AnnouncementResult::Accept));
849 } 898 }
850 899
851 #[test] 900 #[test]
852 fn test_validate_announcement_domain_with_trailing_slash() { 901 fn test_validate_announcement_domain_with_trailing_slash() {
902 use crate::config::ArchiveConfig;
903 use crate::nostr::policy::AnnouncementResult;
904
853 let keys = create_test_keys(); 905 let keys = create_test_keys();
854 let event = create_announcement_event( 906 let event = create_announcement_event(
855 &keys, 907 &keys,
@@ -859,8 +911,8 @@ mod tests {
859 ); 911 );
860 912
861 // Should accept even when domain parameter has trailing slash 913 // Should accept even when domain parameter has trailing slash
862 let result = validate_announcement(&event, "gitnostr.com/"); 914 let result = validate_announcement(&event, "gitnostr.com/", &ArchiveConfig::default());
863 assert!(result.is_ok()); 915 assert!(matches!(result, AnnouncementResult::Accept));
864 } 916 }
865 917
866 #[test] 918 #[test]
@@ -896,4 +948,159 @@ mod tests {
896 assert!(announcement.has_relay("example.com")); 948 assert!(announcement.has_relay("example.com"));
897 assert!(announcement.has_relay("example.com/")); 949 assert!(announcement.has_relay("example.com/"));
898 } 950 }
951
952 #[test]
953 fn test_validate_announcement_archive_mode_npub() {
954 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
955 use crate::nostr::policy::AnnouncementResult;
956
957 let keys = create_test_keys();
958 let npub = keys.public_key().to_bech32().unwrap();
959
960 // Create announcement that does NOT list our service
961 let event = create_announcement_event(
962 &keys,
963 "test-repo",
964 vec!["https://other-service.com/alice/test-repo.git"],
965 vec!["wss://other-service.com"],
966 );
967
968 // Create archive config that whitelists this npub
969 let archive_config = ArchiveConfig {
970 archive_all: false,
971 whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)],
972 };
973
974 let result = validate_announcement(&event, "gitnostr.com", &archive_config);
975 assert!(matches!(result, AnnouncementResult::AcceptArchive));
976 }
977
978 #[test]
979 fn test_validate_announcement_archive_mode_identifier() {
980 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
981 use crate::nostr::policy::AnnouncementResult;
982
983 let keys = create_test_keys();
984
985 // Create announcement that does NOT list our service
986 let event = create_announcement_event(
987 &keys,
988 "bitcoin-core",
989 vec!["https://other-service.com/alice/bitcoin-core.git"],
990 vec!["wss://other-service.com"],
991 );
992
993 // Create archive config that whitelists this identifier
994 let archive_config = ArchiveConfig {
995 archive_all: false,
996 whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
997 };
998
999 let result = validate_announcement(&event, "gitnostr.com", &archive_config);
1000 assert!(matches!(result, AnnouncementResult::AcceptArchive));
1001 }
1002
1003 #[test]
1004 fn test_validate_announcement_archive_mode_repository() {
1005 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
1006 use crate::nostr::policy::AnnouncementResult;
1007
1008 let keys = create_test_keys();
1009 let npub = keys.public_key().to_bech32().unwrap();
1010
1011 // Create announcement that does NOT list our service
1012 let event = create_announcement_event(
1013 &keys,
1014 "linux",
1015 vec!["https://other-service.com/alice/linux.git"],
1016 vec!["wss://other-service.com"],
1017 );
1018
1019 // Create archive config that whitelists this specific repo
1020 let archive_config = ArchiveConfig {
1021 archive_all: false,
1022 whitelist: vec![ArchiveWhitelistEntry::Repository {
1023 npub,
1024 identifier: "linux".into(),
1025 }],
1026 };
1027
1028 let result = validate_announcement(&event, "gitnostr.com", &archive_config);
1029 assert!(matches!(result, AnnouncementResult::AcceptArchive));
1030 }
1031
1032 #[test]
1033 fn test_validate_announcement_archive_all() {
1034 use crate::config::ArchiveConfig;
1035 use crate::nostr::policy::AnnouncementResult;
1036
1037 let keys = create_test_keys();
1038
1039 // Create announcement that does NOT list our service
1040 let event = create_announcement_event(
1041 &keys,
1042 "any-repo",
1043 vec!["https://other-service.com/alice/any-repo.git"],
1044 vec!["wss://other-service.com"],
1045 );
1046
1047 // Create archive config with archive_all enabled
1048 let archive_config = ArchiveConfig {
1049 archive_all: true,
1050 whitelist: Vec::new(),
1051 };
1052
1053 let result = validate_announcement(&event, "gitnostr.com", &archive_config);
1054 assert!(matches!(result, AnnouncementResult::AcceptArchive));
1055 }
1056
1057 #[test]
1058 fn test_validate_announcement_reject_not_in_whitelist() {
1059 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
1060 use crate::nostr::policy::AnnouncementResult;
1061
1062 let keys = create_test_keys();
1063
1064 // Create announcement that does NOT list our service
1065 let event = create_announcement_event(
1066 &keys,
1067 "other-repo",
1068 vec!["https://other-service.com/alice/other-repo.git"],
1069 vec!["wss://other-service.com"],
1070 );
1071
1072 // Create archive config that whitelists different identifier
1073 let archive_config = ArchiveConfig {
1074 archive_all: false,
1075 whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
1076 };
1077
1078 let result = validate_announcement(&event, "gitnostr.com", &archive_config);
1079 assert!(matches!(result, AnnouncementResult::Reject(_)));
1080 }
1081
1082 #[test]
1083 fn test_validate_announcement_grasp01_takes_precedence() {
1084 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
1085 use crate::nostr::policy::AnnouncementResult;
1086
1087 let keys = create_test_keys();
1088
1089 // Create announcement that DOES list our service
1090 let event = create_announcement_event(
1091 &keys,
1092 "test-repo",
1093 vec!["https://gitnostr.com/alice/test-repo.git"],
1094 vec!["wss://gitnostr.com"],
1095 );
1096
1097 // Even with archive config, GRASP-01 Accept takes precedence
1098 let archive_config = ArchiveConfig {
1099 archive_all: true,
1100 whitelist: Vec::new(),
1101 };
1102
1103 let result = validate_announcement(&event, "gitnostr.com", &archive_config);
1104 assert!(matches!(result, AnnouncementResult::Accept));
1105 }
899} 1106}
diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs
index 61840fb..db87976 100644
--- a/src/nostr/policy/announcement.rs
+++ b/src/nostr/policy/announcement.rs
@@ -5,15 +5,18 @@
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; 5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6 6
7use super::PolicyContext; 7use super::PolicyContext;
8use crate::config::ArchiveConfig;
8use crate::nostr::events::{validate_announcement, RepositoryAnnouncement}; 9use crate::nostr::events::{validate_announcement, RepositoryAnnouncement};
9 10
10/// Result of announcement policy evaluation 11/// Result of announcement policy evaluation
11#[derive(Debug)] 12#[derive(Debug, Clone, PartialEq)]
12pub enum AnnouncementResult { 13pub enum AnnouncementResult {
13 /// Accept: Event passes validation 14 /// Accept: Event lists our service (GRASP-01 compliant)
14 Accept, 15 Accept,
15 /// Accept as maintainer: Event accepted via maintainer exception 16 /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer)
16 AcceptMaintainer, 17 AcceptMaintainer,
18 /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only)
19 AcceptArchive,
17 /// Reject: Event fails validation with reason 20 /// Reject: Event fails validation with reason
18 Reject(String), 21 Reject(String),
19} 22}
@@ -22,31 +25,34 @@ pub enum AnnouncementResult {
22#[derive(Clone)] 25#[derive(Clone)]
23pub struct AnnouncementPolicy { 26pub struct AnnouncementPolicy {
24 ctx: PolicyContext, 27 ctx: PolicyContext,
28 archive_config: ArchiveConfig,
25} 29}
26 30
27impl AnnouncementPolicy { 31impl AnnouncementPolicy {
28 pub fn new(ctx: PolicyContext) -> Self { 32 pub fn new(ctx: PolicyContext, archive_config: ArchiveConfig) -> Self {
29 Self { ctx } 33 Self {
34 ctx,
35 archive_config,
36 }
30 } 37 }
31 38
32 /// Validate a repository announcement event 39 /// Validate a repository announcement event
33 /// 40 ///
34 /// Returns `Accept` if the announcement lists the service properly, 41 /// Returns `Accept` if the announcement lists the service properly,
35 /// `AcceptMaintainer` if accepted via maintainer exception, 42 /// `AcceptMaintainer` if accepted via maintainer exception,
43 /// `AcceptArchive` if accepted via GRASP-05 archive config,
36 /// or `Reject` with reason. 44 /// or `Reject` with reason.
37 pub async fn validate(&self, event: &Event) -> AnnouncementResult { 45 pub async fn validate(&self, event: &Event) -> AnnouncementResult {
38 // First, try normal validation (announcement lists service) 46 // First, try validation (GRASP-01 + GRASP-05)
39 match validate_announcement(event, &self.ctx.domain) { 47 let validation_result =
40 Ok(_) => AnnouncementResult::Accept, 48 validate_announcement(event, &self.ctx.domain, &self.archive_config);
41 Err(validation_err) => {
42 // Validation failed - check if this is a recursive maintainer announcement
43 // GRASP-01 Exception: Accept announcements from recursive maintainers
44 // even without listing the service, for chain discovery and GRASP-02 sync
45 49
46 // Try to parse the announcement to get identifier 50 match validation_result {
51 AnnouncementResult::Reject(reason) => {
52 // Validation failed - check maintainer exception
53 // GRASP-01 Exception: Accept announcements from recursive maintainers
47 match RepositoryAnnouncement::from_event(event.clone()) { 54 match RepositoryAnnouncement::from_event(event.clone()) {
48 Ok(announcement) => { 55 Ok(announcement) => {
49 // Check if author is listed as maintainer in any existing announcement
50 match self 56 match self
51 .is_maintainer_in_any_announcement( 57 .is_maintainer_in_any_announcement(
52 &announcement.identifier, 58 &announcement.identifier,
@@ -55,16 +61,18 @@ impl AnnouncementPolicy {
55 .await 61 .await
56 { 62 {
57 Ok(true) => AnnouncementResult::AcceptMaintainer, 63 Ok(true) => AnnouncementResult::AcceptMaintainer,
58 Ok(false) => AnnouncementResult::Reject(validation_err.to_string()), 64 Ok(false) => AnnouncementResult::Reject(reason),
59 Err(_) => { 65 Err(_) => {
60 // Fail-secure: reject on database errors 66 // Fail-secure: reject on database errors
61 AnnouncementResult::Reject(validation_err.to_string()) 67 AnnouncementResult::Reject(reason)
62 } 68 }
63 } 69 }
64 } 70 }
65 Err(_) => AnnouncementResult::Reject(validation_err.to_string()), 71 Err(_) => AnnouncementResult::Reject(reason),
66 } 72 }
67 } 73 }
74 // Accept, AcceptArchive, or AcceptMaintainer - return as-is
75 result => result,
68 } 76 }
69 } 77 }
70 78