diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 20:30:13 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 20:30:13 +0000 |
| commit | a12927181c571fc1641772ad44dd4c6a4ab209d9 (patch) | |
| tree | d7cb99fa87606e9fb13d91305cda8a0f919e6528 /src/nostr | |
| parent | c29191b1e1239e931c575a926ec9480e594476d6 (diff) | |
feat(grasp-05): add read-only mode with auto-enable for archive configs
Implements NGIT_ARCHIVE_READ_ONLY configuration option that defaults to true
when archive mode is enabled, allowing relays to operate as read-only syncs
of archived repositories.
Key changes:
- Add NGIT_ARCHIVE_READ_ONLY config option (defaults to true if archive enabled)
- NIP-11 advertises GRASP-05 support and includes curation field when read-only
- Validation logic rejects non-whitelisted repos in read-only mode
- Comprehensive tests for read-only behavior and defaults
- Full documentation in config reference, .env.example, and NixOS module
Read-only mode enables passive mirroring without being listed in announcements,
useful for backup/archive operations while preventing accidental write acceptance.
Diffstat (limited to 'src/nostr')
| -rw-r--r-- | src/nostr/builder.rs | 5 | ||||
| -rw-r--r-- | src/nostr/events.rs | 123 |
2 files changed, 115 insertions, 13 deletions
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( | |||
| 575 | 575 | ||
| 576 | if archive_config.enabled() { | 576 | if archive_config.enabled() { |
| 577 | tracing::info!( | 577 | tracing::info!( |
| 578 | "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}", | 578 | "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}", |
| 579 | archive_config.archive_all, | 579 | archive_config.archive_all, |
| 580 | archive_config.whitelist.len() | 580 | archive_config.whitelist.len(), |
| 581 | archive_config.read_only | ||
| 581 | ); | 582 | ); |
| 582 | } | 583 | } |
| 583 | 584 | ||
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 { | |||
| 362 | /// Validate a repository announcement according to GRASP-01 and GRASP-05 | 362 | /// Validate a repository announcement according to GRASP-01 and GRASP-05 |
| 363 | /// | 363 | /// |
| 364 | /// Returns: | 364 | /// Returns: |
| 365 | /// - Accept: Announcement lists our service (GRASP-01) | 365 | /// - Accept: Announcement lists our service (GRASP-01) - unless archive_read_only mode |
| 366 | /// - AcceptArchive: Announcement matches archive config (GRASP-05) | 366 | /// - AcceptArchive: Announcement matches archive config (GRASP-05) |
| 367 | /// - Reject: Validation failed | 367 | /// - Reject: Validation failed |
| 368 | /// | 368 | /// |
| 369 | /// When archive_read_only is true: | ||
| 370 | /// - ONLY accept announcements matching archive whitelist/all | ||
| 371 | /// - REJECT announcements listing our service but not in whitelist (read-only sync mode) | ||
| 372 | /// | ||
| 369 | /// Note: AcceptMaintainer is NOT returned here (requires database access) | 373 | /// Note: AcceptMaintainer is NOT returned here (requires database access) |
| 370 | pub fn validate_announcement( | 374 | pub fn validate_announcement( |
| 371 | event: &Event, | 375 | event: &Event, |
| @@ -394,23 +398,32 @@ pub fn validate_announcement( | |||
| 394 | Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), | 398 | Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), |
| 395 | }; | 399 | }; |
| 396 | 400 | ||
| 397 | // GRASP-01: Check if announcement lists our service | 401 | // GRASP-01: Normal mode - accept if announcement lists our service |
| 398 | if announcement.lists_service(domain) { | 402 | if announcement.lists_service(domain) && !archive_config.read_only { |
| 399 | return AnnouncementResult::Accept; | 403 | return AnnouncementResult::Accept; |
| 400 | } | 404 | } |
| 401 | 405 | ||
| 402 | // GRASP-05: Check if announcement matches archive configuration | ||
| 403 | let npub = announcement.owner_npub(); | 406 | let npub = announcement.owner_npub(); |
| 407 | |||
| 408 | // GRASP-05: Archive mode - accept if announcement matches whitelist | ||
| 404 | if archive_config.matches(&npub, &announcement.identifier) { | 409 | if archive_config.matches(&npub, &announcement.identifier) { |
| 405 | return AnnouncementResult::AcceptArchive; | 410 | return AnnouncementResult::AcceptArchive; |
| 406 | } | 411 | } |
| 407 | 412 | ||
| 408 | // Reject: Doesn't list us and not whitelisted | 413 | // Reject with appropriate error message |
| 409 | AnnouncementResult::Reject(format!( | 414 | if archive_config.read_only { |
| 410 | "Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \ | 415 | AnnouncementResult::Reject(format!( |
| 411 | Found clone URLs: {:?}, relays: {:?}", | 416 | "Archive read-only mode: announcement must match archive whitelist. \ |
| 412 | announcement.clone_urls, announcement.relays | 417 | Repository {}/{} not in whitelist", |
| 413 | )) | 418 | npub, announcement.identifier |
| 419 | )) | ||
| 420 | } else { | ||
| 421 | AnnouncementResult::Reject(format!( | ||
| 422 | "Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \ | ||
| 423 | Found clone URLs: {:?}, relays: {:?}", | ||
| 424 | announcement.clone_urls, announcement.relays | ||
| 425 | )) | ||
| 426 | } | ||
| 414 | } | 427 | } |
| 415 | 428 | ||
| 416 | /// Validate a repository state announcement according to GRASP-01 | 429 | /// Validate a repository state announcement according to GRASP-01 |
| @@ -969,6 +982,7 @@ mod tests { | |||
| 969 | let archive_config = ArchiveConfig { | 982 | let archive_config = ArchiveConfig { |
| 970 | archive_all: false, | 983 | archive_all: false, |
| 971 | whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], | 984 | whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], |
| 985 | read_only: false, | ||
| 972 | }; | 986 | }; |
| 973 | 987 | ||
| 974 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 988 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -994,6 +1008,7 @@ mod tests { | |||
| 994 | let archive_config = ArchiveConfig { | 1008 | let archive_config = ArchiveConfig { |
| 995 | archive_all: false, | 1009 | archive_all: false, |
| 996 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], | 1010 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], |
| 1011 | read_only: false, | ||
| 997 | }; | 1012 | }; |
| 998 | 1013 | ||
| 999 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1014 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -1023,6 +1038,7 @@ mod tests { | |||
| 1023 | npub, | 1038 | npub, |
| 1024 | identifier: "linux".into(), | 1039 | identifier: "linux".into(), |
| 1025 | }], | 1040 | }], |
| 1041 | read_only: false, | ||
| 1026 | }; | 1042 | }; |
| 1027 | 1043 | ||
| 1028 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1044 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -1048,6 +1064,7 @@ mod tests { | |||
| 1048 | let archive_config = ArchiveConfig { | 1064 | let archive_config = ArchiveConfig { |
| 1049 | archive_all: true, | 1065 | archive_all: true, |
| 1050 | whitelist: Vec::new(), | 1066 | whitelist: Vec::new(), |
| 1067 | read_only: false, | ||
| 1051 | }; | 1068 | }; |
| 1052 | 1069 | ||
| 1053 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1070 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -1073,6 +1090,7 @@ mod tests { | |||
| 1073 | let archive_config = ArchiveConfig { | 1090 | let archive_config = ArchiveConfig { |
| 1074 | archive_all: false, | 1091 | archive_all: false, |
| 1075 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], | 1092 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], |
| 1093 | read_only: false, | ||
| 1076 | }; | 1094 | }; |
| 1077 | 1095 | ||
| 1078 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1096 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| @@ -1094,13 +1112,96 @@ mod tests { | |||
| 1094 | vec!["wss://gitnostr.com"], | 1112 | vec!["wss://gitnostr.com"], |
| 1095 | ); | 1113 | ); |
| 1096 | 1114 | ||
| 1097 | // Even with archive config, GRASP-01 Accept takes precedence | 1115 | // With archive_read_only=false, GRASP-01 Accept takes precedence |
| 1098 | let archive_config = ArchiveConfig { | 1116 | let archive_config = ArchiveConfig { |
| 1099 | archive_all: true, | 1117 | archive_all: true, |
| 1100 | whitelist: Vec::new(), | 1118 | whitelist: Vec::new(), |
| 1119 | read_only: false, | ||
| 1101 | }; | 1120 | }; |
| 1102 | 1121 | ||
| 1103 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | 1122 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); |
| 1104 | assert!(matches!(result, AnnouncementResult::Accept)); | 1123 | assert!(matches!(result, AnnouncementResult::Accept)); |
| 1105 | } | 1124 | } |
| 1125 | |||
| 1126 | #[test] | ||
| 1127 | fn test_archive_read_only_rejects_non_whitelisted() { | ||
| 1128 | use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; | ||
| 1129 | use crate::nostr::policy::AnnouncementResult; | ||
| 1130 | |||
| 1131 | let keys = create_test_keys(); | ||
| 1132 | |||
| 1133 | // Create announcement that DOES list our service | ||
| 1134 | let event = create_announcement_event( | ||
| 1135 | &keys, | ||
| 1136 | "test-repo", | ||
| 1137 | vec!["https://gitnostr.com/alice/test-repo.git"], | ||
| 1138 | vec!["wss://gitnostr.com"], | ||
| 1139 | ); | ||
| 1140 | |||
| 1141 | // With archive_read_only=true and whitelist that doesn't include this repo, | ||
| 1142 | // should reject even though it lists our service | ||
| 1143 | let archive_config = ArchiveConfig { | ||
| 1144 | archive_all: false, | ||
| 1145 | whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], | ||
| 1146 | read_only: true, | ||
| 1147 | }; | ||
| 1148 | |||
| 1149 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | ||
| 1150 | assert!(matches!(result, AnnouncementResult::Reject(_))); | ||
| 1151 | } | ||
| 1152 | |||
| 1153 | #[test] | ||
| 1154 | fn test_archive_read_only_accepts_whitelisted() { | ||
| 1155 | use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; | ||
| 1156 | use crate::nostr::policy::AnnouncementResult; | ||
| 1157 | |||
| 1158 | let keys = create_test_keys(); | ||
| 1159 | let npub = keys.public_key().to_bech32().unwrap(); | ||
| 1160 | |||
| 1161 | // Create announcement that lists our service | ||
| 1162 | let event = create_announcement_event( | ||
| 1163 | &keys, | ||
| 1164 | "test-repo", | ||
| 1165 | vec!["https://gitnostr.com/alice/test-repo.git"], | ||
| 1166 | vec!["wss://gitnostr.com"], | ||
| 1167 | ); | ||
| 1168 | |||
| 1169 | // With archive_read_only=true and whitelist that DOES include this repo, | ||
| 1170 | // should accept as AcceptArchive | ||
| 1171 | let archive_config = ArchiveConfig { | ||
| 1172 | archive_all: false, | ||
| 1173 | whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], | ||
| 1174 | read_only: true, | ||
| 1175 | }; | ||
| 1176 | |||
| 1177 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | ||
| 1178 | assert!(matches!(result, AnnouncementResult::AcceptArchive)); | ||
| 1179 | } | ||
| 1180 | |||
| 1181 | #[test] | ||
| 1182 | fn test_archive_read_only_with_archive_all() { | ||
| 1183 | use crate::config::ArchiveConfig; | ||
| 1184 | use crate::nostr::policy::AnnouncementResult; | ||
| 1185 | |||
| 1186 | let keys = create_test_keys(); | ||
| 1187 | |||
| 1188 | // Create announcement that lists our service | ||
| 1189 | let event = create_announcement_event( | ||
| 1190 | &keys, | ||
| 1191 | "any-repo", | ||
| 1192 | vec!["https://gitnostr.com/alice/any-repo.git"], | ||
| 1193 | vec!["wss://gitnostr.com"], | ||
| 1194 | ); | ||
| 1195 | |||
| 1196 | // With archive_read_only=true and archive_all=true, | ||
| 1197 | // should accept as AcceptArchive | ||
| 1198 | let archive_config = ArchiveConfig { | ||
| 1199 | archive_all: true, | ||
| 1200 | whitelist: Vec::new(), | ||
| 1201 | read_only: true, | ||
| 1202 | }; | ||
| 1203 | |||
| 1204 | let result = validate_announcement(&event, "gitnostr.com", &archive_config); | ||
| 1205 | assert!(matches!(result, AnnouncementResult::AcceptArchive)); | ||
| 1206 | } | ||
| 1106 | } | 1207 | } |