diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-21 13:28:37 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-21 13:38:11 +0000 |
| commit | 46fbcc0a4c8a8dbf6cd345d6eaa6fe33a82100bb (patch) | |
| tree | 6ab52486732077dbab80907d974c195b1e2f7216 /src | |
| parent | 780d09b0c1eb823f02fc61de6dbf99b2d5cefaca (diff) | |
feat: add archive-grasp-services configuration option
Enables relay operators to backup/archive specific GRASP servers by domain.
Includes configuration, validation, documentation, and integration tests.
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.rs | 265 | ||||
| -rw-r--r-- | src/nostr/events.rs | 11 |
2 files changed, 269 insertions, 7 deletions
diff --git a/src/config.rs b/src/config.rs index 0a867e3..4b1e8d9 100644 --- a/src/config.rs +++ b/src/config.rs | |||
| @@ -122,6 +122,11 @@ pub struct ArchiveConfig { | |||
| 122 | /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). | 122 | /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). |
| 123 | pub whitelist: Vec<WhitelistEntry>, | 123 | pub whitelist: Vec<WhitelistEntry>, |
| 124 | 124 | ||
| 125 | /// GRASP server domains to archive (archive all repositories from these domains) | ||
| 126 | /// | ||
| 127 | /// If non-empty, archives all repositories from the specified GRASP server domains. | ||
| 128 | pub grasp_services: Vec<String>, | ||
| 129 | |||
| 125 | /// Read-only archive mode: relay is a read-only sync of archived repositories | 130 | /// Read-only archive mode: relay is a read-only sync of archived repositories |
| 126 | /// | 131 | /// |
| 127 | /// When true, the relay ONLY accepts announcements matching the archive whitelist/all. | 132 | /// When true, the relay ONLY accepts announcements matching the archive whitelist/all. |
| @@ -131,9 +136,9 @@ pub struct ArchiveConfig { | |||
| 131 | } | 136 | } |
| 132 | 137 | ||
| 133 | impl ArchiveConfig { | 138 | impl ArchiveConfig { |
| 134 | /// Check if GRASP-05 is enabled (either archive_all or non-empty whitelist) | 139 | /// Check if GRASP-05 is enabled (either archive_all, non-empty whitelist, or non-empty grasp_services) |
| 135 | pub fn enabled(&self) -> bool { | 140 | pub fn enabled(&self) -> bool { |
| 136 | self.archive_all || !self.whitelist.is_empty() | 141 | self.archive_all || !self.whitelist.is_empty() || !self.grasp_services.is_empty() |
| 137 | } | 142 | } |
| 138 | 143 | ||
| 139 | /// Check if an announcement matches the archive configuration | 144 | /// Check if an announcement matches the archive configuration |
| @@ -141,6 +146,7 @@ impl ArchiveConfig { | |||
| 141 | /// Returns true if: | 146 | /// Returns true if: |
| 142 | /// - archive_all is true, OR | 147 | /// - archive_all is true, OR |
| 143 | /// - announcement matches any whitelist entry | 148 | /// - announcement matches any whitelist entry |
| 149 | /// Note: grasp_services matching is handled via matches_grasp_services() | ||
| 144 | pub fn matches(&self, npub: &str, identifier: &str) -> bool { | 150 | pub fn matches(&self, npub: &str, identifier: &str) -> bool { |
| 145 | if self.archive_all { | 151 | if self.archive_all { |
| 146 | return true; | 152 | return true; |
| @@ -150,6 +156,19 @@ impl ArchiveConfig { | |||
| 150 | .iter() | 156 | .iter() |
| 151 | .any(|entry| entry.matches(npub, identifier)) | 157 | .any(|entry| entry.matches(npub, identifier)) |
| 152 | } | 158 | } |
| 159 | |||
| 160 | /// Check if any of the given domains match the configured grasp_services | ||
| 161 | /// | ||
| 162 | /// Returns true if any domain in the list matches any configured grasp_services entry. | ||
| 163 | pub fn matches_grasp_services(&self, domains: &[String]) -> bool { | ||
| 164 | if self.grasp_services.is_empty() { | ||
| 165 | return false; | ||
| 166 | } | ||
| 167 | |||
| 168 | domains | ||
| 169 | .iter() | ||
| 170 | .any(|domain| self.grasp_services.iter().any(|service| service == domain)) | ||
| 171 | } | ||
| 153 | } | 172 | } |
| 154 | 173 | ||
| 155 | impl Default for ArchiveConfig { | 174 | impl Default for ArchiveConfig { |
| @@ -157,6 +176,7 @@ impl Default for ArchiveConfig { | |||
| 157 | Self { | 176 | Self { |
| 158 | archive_all: false, | 177 | archive_all: false, |
| 159 | whitelist: Vec::new(), | 178 | whitelist: Vec::new(), |
| 179 | grasp_services: Vec::new(), | ||
| 160 | read_only: false, | 180 | read_only: false, |
| 161 | } | 181 | } |
| 162 | } | 182 | } |
| @@ -447,9 +467,15 @@ pub struct Config { | |||
| 447 | #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] | 467 | #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] |
| 448 | pub archive_whitelist: String, | 468 | pub archive_whitelist: String, |
| 449 | 469 | ||
| 470 | /// GRASP-05 archive GRASP services: comma-separated list of GRASP server domains to archive | ||
| 471 | /// When set, archives all repositories from the specified GRASP server domains | ||
| 472 | /// Mutually exclusive with archive_all and archive_whitelist | ||
| 473 | #[arg(long, env = "NGIT_ARCHIVE_GRASP_SERVICES", default_value = "")] | ||
| 474 | pub archive_grasp_services: String, | ||
| 475 | |||
| 450 | /// Archive read-only mode: relay is a read-only sync of archived repositories | 476 | /// Archive read-only mode: relay is a read-only sync of archived repositories |
| 451 | /// Defaults to true if archive_all or archive_whitelist is set, false otherwise | 477 | /// Defaults to true if archive_all, archive_whitelist, or archive_grasp_services is set, false otherwise |
| 452 | /// Throws error if set to true without archive_all or archive_whitelist | 478 | /// Throws error if set to true without archive_all, archive_whitelist, or archive_grasp_services |
| 453 | #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] | 479 | #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] |
| 454 | pub archive_read_only: Option<bool>, | 480 | pub archive_read_only: Option<bool>, |
| 455 | 481 | ||
| @@ -589,13 +615,32 @@ impl Config { | |||
| 589 | 615 | ||
| 590 | // Validate archive configuration | 616 | // Validate archive configuration |
| 591 | let archive_whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); | 617 | let archive_whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); |
| 592 | let archive_enabled = self.archive_all || !archive_whitelist.is_empty(); | 618 | let archive_grasp_services = self.parse_archive_grasp_services(); |
| 619 | let archive_enabled = | ||
| 620 | self.archive_all || !archive_whitelist.is_empty() || !archive_grasp_services.is_empty(); | ||
| 621 | |||
| 622 | // Fatal error: archive_grasp_services cannot be used with archive_all or archive_whitelist | ||
| 623 | if !archive_grasp_services.is_empty() { | ||
| 624 | if self.archive_all { | ||
| 625 | return Err(anyhow!( | ||
| 626 | "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with NGIT_ARCHIVE_ALL=true. \ | ||
| 627 | These options are mutually exclusive." | ||
| 628 | )); | ||
| 629 | } | ||
| 630 | if !archive_whitelist.is_empty() { | ||
| 631 | return Err(anyhow!( | ||
| 632 | "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with NGIT_ARCHIVE_WHITELIST. \ | ||
| 633 | These options are mutually exclusive." | ||
| 634 | )); | ||
| 635 | } | ||
| 636 | } | ||
| 593 | 637 | ||
| 594 | // Fatal error: archive_read_only=true without archive mode enabled | 638 | // Fatal error: archive_read_only=true without archive mode enabled |
| 595 | if let Some(true) = self.archive_read_only { | 639 | if let Some(true) = self.archive_read_only { |
| 596 | if !archive_enabled { | 640 | if !archive_enabled { |
| 597 | return Err(anyhow!( | 641 | return Err(anyhow!( |
| 598 | "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" | 642 | "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true, \ |
| 643 | NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES to be set" | ||
| 599 | )); | 644 | )); |
| 600 | } | 645 | } |
| 601 | } | 646 | } |
| @@ -619,13 +664,32 @@ impl Config { | |||
| 619 | Ok(()) | 664 | Ok(()) |
| 620 | } | 665 | } |
| 621 | 666 | ||
| 667 | /// Parse archive GRASP services from comma-separated string | ||
| 668 | /// | ||
| 669 | /// Returns a list of domain names (GRASP server domains to archive). | ||
| 670 | /// Whitespace is trimmed and empty entries are ignored. | ||
| 671 | pub fn parse_archive_grasp_services(&self) -> Vec<String> { | ||
| 672 | if self.archive_grasp_services.trim().is_empty() { | ||
| 673 | return Vec::new(); | ||
| 674 | } | ||
| 675 | |||
| 676 | self.archive_grasp_services | ||
| 677 | .split(',') | ||
| 678 | .map(|s| s.trim()) | ||
| 679 | .filter(|s| !s.is_empty()) | ||
| 680 | .map(|s| s.to_string()) | ||
| 681 | .collect() | ||
| 682 | } | ||
| 683 | |||
| 622 | /// Get parsed archive configuration with computed read-only mode | 684 | /// Get parsed archive configuration with computed read-only mode |
| 623 | /// | 685 | /// |
| 624 | /// Read-only mode defaults to true if archive mode is enabled, false otherwise. | 686 | /// Read-only mode defaults to true if archive mode is enabled, false otherwise. |
| 625 | /// This method assumes config has been validated - call Config::validate() first! | 687 | /// This method assumes config has been validated - call Config::validate() first! |
| 626 | pub fn archive_config(&self) -> ArchiveConfig { | 688 | pub fn archive_config(&self) -> ArchiveConfig { |
| 627 | let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); | 689 | let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); |
| 628 | let archive_enabled = self.archive_all || !whitelist.is_empty(); | 690 | let archive_grasp_services = self.parse_archive_grasp_services(); |
| 691 | let archive_enabled = | ||
| 692 | self.archive_all || !whitelist.is_empty() || !archive_grasp_services.is_empty(); | ||
| 629 | 693 | ||
| 630 | let read_only = match self.archive_read_only { | 694 | let read_only = match self.archive_read_only { |
| 631 | Some(true) => true, // Already validated in validate() | 695 | Some(true) => true, // Already validated in validate() |
| @@ -639,6 +703,7 @@ impl Config { | |||
| 639 | ArchiveConfig { | 703 | ArchiveConfig { |
| 640 | archive_all: self.archive_all, | 704 | archive_all: self.archive_all, |
| 641 | whitelist, | 705 | whitelist, |
| 706 | grasp_services: archive_grasp_services, | ||
| 642 | read_only, | 707 | read_only, |
| 643 | } | 708 | } |
| 644 | } | 709 | } |
| @@ -705,6 +770,7 @@ impl Config { | |||
| 705 | naughty_list_expiration_hours: 12, | 770 | naughty_list_expiration_hours: 12, |
| 706 | archive_all: false, | 771 | archive_all: false, |
| 707 | archive_whitelist: String::new(), | 772 | archive_whitelist: String::new(), |
| 773 | archive_grasp_services: String::new(), | ||
| 708 | archive_read_only: None, | 774 | archive_read_only: None, |
| 709 | repository_whitelist: String::new(), | 775 | repository_whitelist: String::new(), |
| 710 | repository_blacklist: String::new(), | 776 | repository_blacklist: String::new(), |
| @@ -936,6 +1002,7 @@ mod tests { | |||
| 936 | let config = ArchiveConfig { | 1002 | let config = ArchiveConfig { |
| 937 | archive_all: true, | 1003 | archive_all: true, |
| 938 | whitelist: Vec::new(), | 1004 | whitelist: Vec::new(), |
| 1005 | grasp_services: Vec::new(), | ||
| 939 | read_only: true, | 1006 | read_only: true, |
| 940 | }; | 1007 | }; |
| 941 | assert!(config.enabled()); | 1008 | assert!(config.enabled()); |
| @@ -943,6 +1010,7 @@ mod tests { | |||
| 943 | let config = ArchiveConfig { | 1010 | let config = ArchiveConfig { |
| 944 | archive_all: false, | 1011 | archive_all: false, |
| 945 | whitelist: vec![WhitelistEntry::Identifier("test".into())], | 1012 | whitelist: vec![WhitelistEntry::Identifier("test".into())], |
| 1013 | grasp_services: Vec::new(), | ||
| 946 | read_only: true, | 1014 | read_only: true, |
| 947 | }; | 1015 | }; |
| 948 | assert!(config.enabled()); | 1016 | assert!(config.enabled()); |
| @@ -958,6 +1026,7 @@ mod tests { | |||
| 958 | WhitelistEntry::Pubkey(test_npub.clone()), | 1026 | WhitelistEntry::Pubkey(test_npub.clone()), |
| 959 | WhitelistEntry::Identifier("bitcoin-core".into()), | 1027 | WhitelistEntry::Identifier("bitcoin-core".into()), |
| 960 | ], | 1028 | ], |
| 1029 | grasp_services: Vec::new(), | ||
| 961 | read_only: false, | 1030 | read_only: false, |
| 962 | }; | 1031 | }; |
| 963 | 1032 | ||
| @@ -971,6 +1040,7 @@ mod tests { | |||
| 971 | let config = ArchiveConfig { | 1040 | let config = ArchiveConfig { |
| 972 | archive_all: true, | 1041 | archive_all: true, |
| 973 | whitelist: Vec::new(), | 1042 | whitelist: Vec::new(), |
| 1043 | grasp_services: Vec::new(), | ||
| 974 | read_only: true, | 1044 | read_only: true, |
| 975 | }; | 1045 | }; |
| 976 | 1046 | ||
| @@ -1379,4 +1449,185 @@ mod tests { | |||
| 1379 | let result = config.check(&allowed_npub); | 1449 | let result = config.check(&allowed_npub); |
| 1380 | assert!(result.is_none()); | 1450 | assert!(result.is_none()); |
| 1381 | } | 1451 | } |
| 1452 | |||
| 1453 | #[test] | ||
| 1454 | fn test_parse_archive_grasp_services_empty() { | ||
| 1455 | let config = Config::for_testing(); | ||
| 1456 | let services = config.parse_archive_grasp_services(); | ||
| 1457 | assert!(services.is_empty()); | ||
| 1458 | |||
| 1459 | let config = Config { | ||
| 1460 | archive_grasp_services: " ".to_string(), | ||
| 1461 | ..Config::for_testing() | ||
| 1462 | }; | ||
| 1463 | let services = config.parse_archive_grasp_services(); | ||
| 1464 | assert!(services.is_empty()); | ||
| 1465 | } | ||
| 1466 | |||
| 1467 | #[test] | ||
| 1468 | fn test_parse_archive_grasp_services_single() { | ||
| 1469 | let config = Config { | ||
| 1470 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1471 | ..Config::for_testing() | ||
| 1472 | }; | ||
| 1473 | let services = config.parse_archive_grasp_services(); | ||
| 1474 | assert_eq!(services.len(), 1); | ||
| 1475 | assert_eq!(services[0], "git.example.com"); | ||
| 1476 | } | ||
| 1477 | |||
| 1478 | #[test] | ||
| 1479 | fn test_parse_archive_grasp_services_multiple() { | ||
| 1480 | let config = Config { | ||
| 1481 | archive_grasp_services: "git.example.com,git.nostr.dev,relay.gitnostr.com".to_string(), | ||
| 1482 | ..Config::for_testing() | ||
| 1483 | }; | ||
| 1484 | let services = config.parse_archive_grasp_services(); | ||
| 1485 | assert_eq!(services.len(), 3); | ||
| 1486 | assert_eq!(services[0], "git.example.com"); | ||
| 1487 | assert_eq!(services[1], "git.nostr.dev"); | ||
| 1488 | assert_eq!(services[2], "relay.gitnostr.com"); | ||
| 1489 | } | ||
| 1490 | |||
| 1491 | #[test] | ||
| 1492 | fn test_parse_archive_grasp_services_with_whitespace() { | ||
| 1493 | let config = Config { | ||
| 1494 | archive_grasp_services: " git.example.com , git.nostr.dev , relay.gitnostr.com " | ||
| 1495 | .to_string(), | ||
| 1496 | ..Config::for_testing() | ||
| 1497 | }; | ||
| 1498 | let services = config.parse_archive_grasp_services(); | ||
| 1499 | assert_eq!(services.len(), 3); | ||
| 1500 | assert_eq!(services[0], "git.example.com"); | ||
| 1501 | assert_eq!(services[1], "git.nostr.dev"); | ||
| 1502 | assert_eq!(services[2], "relay.gitnostr.com"); | ||
| 1503 | } | ||
| 1504 | |||
| 1505 | #[test] | ||
| 1506 | fn test_archive_grasp_services_validation_error_with_archive_all() { | ||
| 1507 | let config = Config { | ||
| 1508 | archive_all: true, | ||
| 1509 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1510 | ..Config::for_testing() | ||
| 1511 | }; | ||
| 1512 | let result = config.validate(); | ||
| 1513 | assert!(result.is_err()); | ||
| 1514 | let err = result.unwrap_err().to_string(); | ||
| 1515 | assert!(err.contains("NGIT_ARCHIVE_GRASP_SERVICES")); | ||
| 1516 | assert!(err.contains("NGIT_ARCHIVE_ALL")); | ||
| 1517 | assert!(err.contains("mutually exclusive")); | ||
| 1518 | } | ||
| 1519 | |||
| 1520 | #[test] | ||
| 1521 | fn test_archive_grasp_services_validation_error_with_archive_whitelist() { | ||
| 1522 | let keys = Keys::generate(); | ||
| 1523 | let test_npub = keys.public_key().to_bech32().unwrap(); | ||
| 1524 | let config = Config { | ||
| 1525 | archive_whitelist: test_npub, | ||
| 1526 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1527 | ..Config::for_testing() | ||
| 1528 | }; | ||
| 1529 | let result = config.validate(); | ||
| 1530 | assert!(result.is_err()); | ||
| 1531 | let err = result.unwrap_err().to_string(); | ||
| 1532 | assert!(err.contains("NGIT_ARCHIVE_GRASP_SERVICES")); | ||
| 1533 | assert!(err.contains("NGIT_ARCHIVE_WHITELIST")); | ||
| 1534 | assert!(err.contains("mutually exclusive")); | ||
| 1535 | } | ||
| 1536 | |||
| 1537 | #[test] | ||
| 1538 | fn test_archive_grasp_services_enables_archive_mode() { | ||
| 1539 | let config = Config { | ||
| 1540 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1541 | ..Config::for_testing() | ||
| 1542 | }; | ||
| 1543 | let archive_config = config.archive_config(); | ||
| 1544 | assert!(archive_config.enabled()); | ||
| 1545 | assert_eq!(archive_config.read_only, true); // Default to true | ||
| 1546 | } | ||
| 1547 | |||
| 1548 | #[test] | ||
| 1549 | fn test_archive_grasp_services_read_only_default() { | ||
| 1550 | // Default: true when archive_grasp_services is set | ||
| 1551 | let config = Config { | ||
| 1552 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1553 | ..Config::for_testing() | ||
| 1554 | }; | ||
| 1555 | assert_eq!(config.archive_config().read_only, true); | ||
| 1556 | } | ||
| 1557 | |||
| 1558 | #[test] | ||
| 1559 | fn test_archive_grasp_services_read_only_explicit_false() { | ||
| 1560 | // Explicit false should be respected | ||
| 1561 | let config = Config { | ||
| 1562 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1563 | archive_read_only: Some(false), | ||
| 1564 | ..Config::for_testing() | ||
| 1565 | }; | ||
| 1566 | assert_eq!(config.archive_config().read_only, false); | ||
| 1567 | } | ||
| 1568 | |||
| 1569 | #[test] | ||
| 1570 | fn test_archive_read_only_validation_with_grasp_services() { | ||
| 1571 | // Should succeed with archive_grasp_services set | ||
| 1572 | let config = Config { | ||
| 1573 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1574 | archive_read_only: Some(true), | ||
| 1575 | ..Config::for_testing() | ||
| 1576 | }; | ||
| 1577 | assert!(config.validate().is_ok()); | ||
| 1578 | } | ||
| 1579 | |||
| 1580 | #[test] | ||
| 1581 | fn test_archive_config_matches_grasp_services() { | ||
| 1582 | let config = ArchiveConfig { | ||
| 1583 | archive_all: false, | ||
| 1584 | whitelist: Vec::new(), | ||
| 1585 | grasp_services: vec!["git.example.com".to_string(), "gitlab.org".to_string()], | ||
| 1586 | read_only: true, | ||
| 1587 | }; | ||
| 1588 | |||
| 1589 | // Should match configured services | ||
| 1590 | assert!(config.matches_grasp_services(&["git.example.com".to_string()])); | ||
| 1591 | assert!(config.matches_grasp_services(&["gitlab.org".to_string()])); | ||
| 1592 | |||
| 1593 | // Should not match unconfigured services | ||
| 1594 | assert!(!config.matches_grasp_services(&["github.com".to_string()])); | ||
| 1595 | assert!(!config.matches_grasp_services(&["other.com".to_string()])); | ||
| 1596 | } | ||
| 1597 | |||
| 1598 | #[test] | ||
| 1599 | fn test_archive_config_matches_grasp_services_empty() { | ||
| 1600 | let config = ArchiveConfig { | ||
| 1601 | archive_all: false, | ||
| 1602 | whitelist: Vec::new(), | ||
| 1603 | grasp_services: Vec::new(), | ||
| 1604 | read_only: true, | ||
| 1605 | }; | ||
| 1606 | |||
| 1607 | // Should not match anything when grasp_services is empty | ||
| 1608 | assert!(!config.matches_grasp_services(&["git.example.com".to_string()])); | ||
| 1609 | assert!(!config.matches_grasp_services(&[])); | ||
| 1610 | } | ||
| 1611 | |||
| 1612 | #[test] | ||
| 1613 | fn test_archive_config_matches_grasp_services_multiple_domains() { | ||
| 1614 | let config = ArchiveConfig { | ||
| 1615 | archive_all: false, | ||
| 1616 | whitelist: Vec::new(), | ||
| 1617 | grasp_services: vec!["git.example.com".to_string()], | ||
| 1618 | read_only: true, | ||
| 1619 | }; | ||
| 1620 | |||
| 1621 | // Should match if any domain matches | ||
| 1622 | assert!(config.matches_grasp_services(&[ | ||
| 1623 | "github.com".to_string(), | ||
| 1624 | "git.example.com".to_string(), | ||
| 1625 | "gitlab.org".to_string(), | ||
| 1626 | ])); | ||
| 1627 | |||
| 1628 | // Should not match if no domain matches | ||
| 1629 | assert!( | ||
| 1630 | !config.matches_grasp_services(&["github.com".to_string(), "gitlab.org".to_string(),]) | ||
| 1631 | ); | ||
| 1632 | } | ||
| 1382 | } | 1633 | } |
diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 1d5a50f..718633e 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs | |||
| @@ -436,6 +436,17 @@ pub fn validate_announcement( | |||
| 436 | return AnnouncementResult::AcceptArchive; | 436 | return AnnouncementResult::AcceptArchive; |
| 437 | } | 437 | } |
| 438 | 438 | ||
| 439 | // GRASP-05: Archive mode - accept if announcement lists any configured GRASP service in clone URLs | ||
| 440 | // Only check clone URLs (not relays) since we're archiving from OTHER services | ||
| 441 | // Check if announcement matches any configured GRASP service domains | ||
| 442 | if archive_config | ||
| 443 | .grasp_services | ||
| 444 | .iter() | ||
| 445 | .any(|service| announcement.has_clone_url(service)) | ||
| 446 | { | ||
| 447 | return AnnouncementResult::AcceptArchive; | ||
| 448 | } | ||
| 449 | |||
| 439 | // Reject with appropriate error message | 450 | // Reject with appropriate error message |
| 440 | if archive_config.read_only { | 451 | if archive_config.read_only { |
| 441 | AnnouncementResult::Reject(format!( | 452 | AnnouncementResult::Reject(format!( |