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 20:30:13 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 20:30:13 +0000
commita12927181c571fc1641772ad44dd4c6a4ab209d9 (patch)
treed7cb99fa87606e9fb13d91305cda8a0f919e6528 /src/nostr
parentc29191b1e1239e931c575a926ec9480e594476d6 (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.rs5
-rw-r--r--src/nostr/events.rs123
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)
370pub fn validate_announcement( 374pub 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}