upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/nostr
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/nostr
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/nostr')
-rw-r--r--src/nostr/builder.rs53
-rw-r--r--src/nostr/events.rs277
-rw-r--r--src/nostr/policy/announcement.rs42
3 files changed, 317 insertions, 55 deletions
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