upleb.uk

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

summaryrefslogtreecommitdiff
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
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.
-rw-r--r--.env.example15
-rw-r--r--README.md4
-rw-r--r--docs/explanation/grasp-05-archive.md25
-rw-r--r--docs/reference/configuration.md71
-rw-r--r--nix/module.nix16
-rw-r--r--src/config.rs107
-rw-r--r--src/http/nip11.rs87
-rw-r--r--src/nostr/builder.rs5
-rw-r--r--src/nostr/events.rs123
9 files changed, 429 insertions, 24 deletions
diff --git a/.env.example b/.env.example
index 2dc5266..cb797a8 100644
--- a/.env.example
+++ b/.env.example
@@ -189,4 +189,17 @@
189# NGIT_ARCHIVE_WHITELIST=npub1alice... 189# NGIT_ARCHIVE_WHITELIST=npub1alice...
190# NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob.../linux 190# NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob.../linux
191# NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust 191# NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust
192# NGIT_ARCHIVE_WHITELIST= \ No newline at end of file 192# NGIT_ARCHIVE_WHITELIST=
193
194# Archive read-only mode (relay is read-only sync of archived repositories)
195# When true:
196# - NIP-11 includes GRASP-05 in supported_grasps
197# - NIP-11 curation field describes archive scope
198# - Repository announcements not listing this service are accepted per whitelist/archive-all
199# When false:
200# - Archive mode disabled (standard GRASP-01 operation)
201#
202# CLI: --archive-read-only
203# Default: true if NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set, false otherwise
204# Note: Setting to true without archive config causes startup error
205# NGIT_ARCHIVE_READ_ONLY= \ No newline at end of file
diff --git a/README.md b/README.md
index 6de9d89..ba6dfa2 100644
--- a/README.md
+++ b/README.md
@@ -141,11 +141,11 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f
141 141
142- ✅ Accept repositories not listing this instance via configurable whitelist 142- ✅ Accept repositories not listing this instance via configurable whitelist
143- ✅ Three whitelist formats: `<npub>`, `<npub>/<identifier>`, `<identifier>` 143- ✅ Three whitelist formats: `<npub>`, `<npub>/<identifier>`, `<identifier>`
144- ✅ Read-only mirroring with full GRASP-02 sync (git data + Nostr events) 144- ✅ Read-only mirroring with full GRASP-02 sync (git data + Nostr events) - **default behavior**
145- ✅ Archive-all mode for complete ecosystem mirrors 145- ✅ Archive-all mode for complete ecosystem mirrors
146- ✅ Fail-fast npub validation at startup 146- ✅ Fail-fast npub validation at startup
147 147
148**Archive mode enables backup/mirror operation** - accept repository announcements that don't list your relay, useful for creating archives of critical projects or running comprehensive mirrors. Archived repositories are read-only with full event and git data sync. 148**Archive mode enables backup/mirror operation** - accept repository announcements that don't list your relay, useful for creating archives of critical projects or running comprehensive mirrors. Archived repositories are read-only by default (`NGIT_ARCHIVE_READ_ONLY=true`) with full event and git data sync.
149 149
150**See**: [GRASP-05 Archive Mode](docs/explanation/grasp-05-archive.md) 150**See**: [GRASP-05 Archive Mode](docs/explanation/grasp-05-archive.md)
151 151
diff --git a/docs/explanation/grasp-05-archive.md b/docs/explanation/grasp-05-archive.md
index e43a87e..45481dd 100644
--- a/docs/explanation/grasp-05-archive.md
+++ b/docs/explanation/grasp-05-archive.md
@@ -35,14 +35,17 @@ Archive mode relaxes the "must list service" requirement for whitelisted reposit
35 35
36**Configuration:** 36**Configuration:**
37```bash 37```bash
38# Specific repos (safest) 38# Specific repos (safest) - read-only by default
39NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin 39NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin
40# NGIT_ARCHIVE_READ_ONLY defaults to true
40 41
41# All repos from trusted maintainers 42# All repos from trusted maintainers
42NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob... 43NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob...
44# NGIT_ARCHIVE_READ_ONLY defaults to true
43 45
44# Archive everything (⚠️ storage risk) 46# Archive everything (⚠️ storage risk)
45NGIT_ARCHIVE_ALL=true 47NGIT_ARCHIVE_ALL=true
48# NGIT_ARCHIVE_READ_ONLY defaults to true
46``` 49```
47 50
48### Validation Priority 51### Validation Priority
@@ -63,11 +66,21 @@ Archived repos use the same directory structure as hosted repos:
63<git_data_path>/ 66<git_data_path>/
64 npub1alice.../ 67 npub1alice.../
65 hosted-repo.git/ # Lists your service (writable) 68 hosted-repo.git/ # Lists your service (writable)
66 archived-repo.git/ # Whitelisted (read-only) 69 archived-repo.git/ # Whitelisted (read-only by default)
67``` 70```
68 71
69**No flags or metadata** - archive status determined dynamically from config + announcement contents. 72**No flags or metadata** - archive status determined dynamically from config + announcement contents.
70 73
74### Read-Only Mode
75
76By default, archive mode operates in read-only mode (`NGIT_ARCHIVE_READ_ONLY=true`):
77- Repository announcements are accepted per whitelist/archive-all configuration
78- The service is **not listed** in accepted announcements (passive sync only)
79- NIP-11 document advertises `GRASP-05` support
80- NIP-11 `curation` field indicates read-only sync scope:
81 - `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`)
82 - `"Read-only sync of whitelisted repositories and maintainers"` (if whitelist configured)
83
71### Full Sync 84### Full Sync
72 85
73Archived repositories trigger complete GRASP-02 sync: 86Archived repositories trigger complete GRASP-02 sync:
@@ -129,12 +142,14 @@ Watch for:
129 142
130## Comparison: Hosted vs Archived 143## Comparison: Hosted vs Archived
131 144
132| Aspect | Hosted (GRASP-01) | Archived (GRASP-05) | 145| Aspect | Hosted (GRASP-01) | Archived (GRASP-05 Read-Only) |
133|--------|-------------------|---------------------| 146|--------|-------------------|-------------------------------|
134| Announcement must list you | ✅ Required | ❌ Whitelisted instead | 147| Announcement must list you | ✅ Required | ❌ Whitelisted instead |
135| Git pushes | ✅ Accepted | ❌ Rejected (read-only) | 148| Git pushes | ✅ Accepted | ❌ Rejected (read-only) |
136| GRASP-02 sync | ✅ Full sync | ✅ Full sync | 149| GRASP-02 sync | ✅ Full sync | ✅ Full sync |
137| Relay discovery | ✅ Listed | ❌ Not listed | 150| Relay discovery | ✅ Listed in announcements | ❌ Not listed (passive sync) |
151| NIP-11 supported_grasps | `["GRASP-01", "GRASP-02"]` | `["GRASP-01", "GRASP-05", "GRASP-02"]` |
152| NIP-11 curation field | `null` | Describes archive scope |
138| Use case | Hosting workspace | Backup/mirror | 153| Use case | Hosting workspace | Backup/mirror |
139 154
140## Related Documentation 155## Related Documentation
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 52418ad..4692600 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -574,6 +574,77 @@ NGIT_ARCHIVE_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core
574 574
575--- 575---
576 576
577#### `NGIT_ARCHIVE_READ_ONLY`
578
579**Description:** Configure relay as read-only sync of archived repositories
580**Type:** Boolean
581**Default:** `true` if `NGIT_ARCHIVE_ALL` or `NGIT_ARCHIVE_WHITELIST` is set, `false` otherwise
582**Required:** No
583
584**Examples:**
585
586```bash
587# Explicitly enable (requires archive mode)
588NGIT_ARCHIVE_READ_ONLY=true
589
590# Explicitly disable (writable archive repos)
591NGIT_ARCHIVE_READ_ONLY=false
592
593# Automatic (default behavior)
594# - If NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set → true
595# - Otherwise → false
596# NGIT_ARCHIVE_READ_ONLY=
597```
598
599**Behavior:**
600
601- When `true`:
602 - NIP-11 document includes `GRASP-05` in `supported_grasps`
603 - NIP-11 `curation` field describes the archive scope
604 - Repository announcements not listing this service are accepted per whitelist/archive-all
605- When `false`:
606 - Archive mode disabled (standard GRASP-01 operation)
607- When unset (default):
608 - Automatically `true` if archive mode configured
609 - Automatically `false` otherwise
610
611**Error Conditions:**
612
613```bash
614# ERROR: Cannot set read-only without archive config
615NGIT_ARCHIVE_READ_ONLY=true
616NGIT_ARCHIVE_ALL=false
617NGIT_ARCHIVE_WHITELIST=
618# → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either
619# NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set"
620```
621
622**NIP-11 Impact:**
623
624When `NGIT_ARCHIVE_READ_ONLY=true`:
625- `supported_grasps`: includes `"GRASP-05"`
626- `curation`: Set to one of:
627 - `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`)
628 - `"Read-only sync of whitelisted repositories and maintainers"` (if `NGIT_ARCHIVE_WHITELIST` set)
629
630**Use Cases:**
631
632```bash
633# Public archive of entire ecosystem
634NGIT_ARCHIVE_ALL=true
635NGIT_ARCHIVE_READ_ONLY=true # Default
636
637# Selective backup of critical projects
638NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin
639NGIT_ARCHIVE_READ_ONLY=true # Default
640
641# Writable mirror (advanced, not typical)
642NGIT_ARCHIVE_WHITELIST=npub1alice...
643NGIT_ARCHIVE_READ_ONLY=false
644```
645
646---
647
577### Logging Configuration 648### Logging Configuration
578 649
579#### `RUST_LOG` 650#### `RUST_LOG`
diff --git a/nix/module.nix b/nix/module.nix
index f82f069..516fb04 100644
--- a/nix/module.nix
+++ b/nix/module.nix
@@ -196,6 +196,20 @@ let
196 ''; 196 '';
197 }; 197 };
198 198
199 archiveReadOnly = mkOption {
200 type = types.nullOr types.bool;
201 default = null;
202 description = ''
203 Archive read-only mode (relay is read-only sync of archived repositories).
204 When true:
205 - NIP-11 includes GRASP-05 in supported_grasps
206 - NIP-11 curation field describes archive scope
207 - Repository announcements not listing this service are accepted per whitelist/archive-all
208 Default: true if archiveAll or archiveWhitelist is set, false otherwise
209 Note: Setting to true without archive config causes startup error
210 '';
211 };
212
199 user = mkOption { 213 user = mkOption {
200 type = types.str; 214 type = types.str;
201 default = "ngit-grasp-${name}"; 215 default = "ngit-grasp-${name}";
@@ -241,6 +255,8 @@ let
241 RUST_LOG = cfg.logLevel; 255 RUST_LOG = cfg.logLevel;
242 } // optionalAttrs (cfg.relayName != null) { 256 } // optionalAttrs (cfg.relayName != null) {
243 NGIT_RELAY_NAME = cfg.relayName; 257 NGIT_RELAY_NAME = cfg.relayName;
258 } // optionalAttrs (cfg.archiveReadOnly != null) {
259 NGIT_ARCHIVE_READ_ONLY = toString cfg.archiveReadOnly;
244 } // optionalAttrs cfg.metricsEnabled { NGIT_METRICS_ENABLED = "true"; } 260 } // optionalAttrs cfg.metricsEnabled { NGIT_METRICS_ENABLED = "true"; }
245 // optionalAttrs (cfg.syncBootstrapRelayUrl != null) { 261 // optionalAttrs (cfg.syncBootstrapRelayUrl != null) {
246 NGIT_SYNC_BOOTSTRAP_RELAY_URL = cfg.syncBootstrapRelayUrl; 262 NGIT_SYNC_BOOTSTRAP_RELAY_URL = cfg.syncBootstrapRelayUrl;
diff --git a/src/config.rs b/src/config.rs
index b1ab43e..d9917a3 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -98,6 +98,13 @@ pub struct ArchiveConfig {
98 /// 98 ///
99 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). 99 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
100 pub whitelist: Vec<ArchiveWhitelistEntry>, 100 pub whitelist: Vec<ArchiveWhitelistEntry>,
101
102 /// Read-only archive mode: relay is a read-only sync of archived repositories
103 ///
104 /// When true, the relay ONLY accepts announcements matching the archive whitelist/all.
105 /// Announcements listing the relay but not in the whitelist are rejected.
106 /// When false, the relay operates in GRASP-01 mode for unwhitelisted repos.
107 pub read_only: bool,
101} 108}
102 109
103impl ArchiveConfig { 110impl ArchiveConfig {
@@ -141,6 +148,7 @@ impl Default for ArchiveConfig {
141 Self { 148 Self {
142 archive_all: false, 149 archive_all: false,
143 whitelist: Vec::new(), 150 whitelist: Vec::new(),
151 read_only: false,
144 } 152 }
145 } 153 }
146} 154}
@@ -311,6 +319,12 @@ pub struct Config {
311 /// Formats: "npub1...", "npub1.../identifier", "identifier" 319 /// Formats: "npub1...", "npub1.../identifier", "identifier"
312 #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] 320 #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")]
313 pub archive_whitelist: String, 321 pub archive_whitelist: String,
322
323 /// Archive read-only mode: relay is a read-only sync of archived repositories
324 /// Defaults to true if archive_all or archive_whitelist is set, false otherwise
325 /// Throws error if set to true without archive_all or archive_whitelist
326 #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")]
327 pub archive_read_only: Option<bool>,
314} 328}
315 329
316impl Config { 330impl Config {
@@ -411,12 +425,34 @@ impl Config {
411 } 425 }
412 } 426 }
413 427
414 /// Get parsed archive configuration 428 /// Get parsed archive configuration with computed read-only mode
429 ///
430 /// Read-only mode defaults to true if archive mode is enabled, false otherwise.
431 /// Throws error if explicitly set to true without archive mode enabled.
415 pub fn archive_config(&self) -> Result<ArchiveConfig> { 432 pub fn archive_config(&self) -> Result<ArchiveConfig> {
416 let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; 433 let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?;
434 let archive_enabled = self.archive_all || !whitelist.is_empty();
435
436 let read_only = match self.archive_read_only {
437 Some(true) => {
438 if !archive_enabled {
439 return Err(anyhow!(
440 "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set"
441 ));
442 }
443 true
444 }
445 Some(false) => false,
446 None => {
447 // Default: true if archive mode enabled, false otherwise
448 archive_enabled
449 }
450 };
451
417 Ok(ArchiveConfig { 452 Ok(ArchiveConfig {
418 archive_all: self.archive_all, 453 archive_all: self.archive_all,
419 whitelist, 454 whitelist,
455 read_only,
420 }) 456 })
421 } 457 }
422 458
@@ -452,6 +488,7 @@ impl Config {
452 naughty_list_expiration_hours: 12, 488 naughty_list_expiration_hours: 12,
453 archive_all: false, 489 archive_all: false,
454 archive_whitelist: String::new(), 490 archive_whitelist: String::new(),
491 archive_read_only: None,
455 } 492 }
456 } 493 }
457} 494}
@@ -664,12 +701,14 @@ mod tests {
664 let config = ArchiveConfig { 701 let config = ArchiveConfig {
665 archive_all: true, 702 archive_all: true,
666 whitelist: Vec::new(), 703 whitelist: Vec::new(),
704 read_only: true,
667 }; 705 };
668 assert!(config.enabled()); 706 assert!(config.enabled());
669 707
670 let config = ArchiveConfig { 708 let config = ArchiveConfig {
671 archive_all: false, 709 archive_all: false,
672 whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], 710 whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())],
711 read_only: true,
673 }; 712 };
674 assert!(config.enabled()); 713 assert!(config.enabled());
675 } 714 }
@@ -684,6 +723,7 @@ mod tests {
684 ArchiveWhitelistEntry::Pubkey(test_npub.clone()), 723 ArchiveWhitelistEntry::Pubkey(test_npub.clone()),
685 ArchiveWhitelistEntry::Identifier("bitcoin-core".into()), 724 ArchiveWhitelistEntry::Identifier("bitcoin-core".into()),
686 ], 725 ],
726 read_only: false,
687 }; 727 };
688 728
689 assert!(config.matches(&test_npub, "any-repo")); 729 assert!(config.matches(&test_npub, "any-repo"));
@@ -696,6 +736,7 @@ mod tests {
696 let config = ArchiveConfig { 736 let config = ArchiveConfig {
697 archive_all: true, 737 archive_all: true,
698 whitelist: Vec::new(), 738 whitelist: Vec::new(),
739 read_only: true,
699 }; 740 };
700 741
701 assert!(config.matches("npub1alice", "any-repo")); 742 assert!(config.matches("npub1alice", "any-repo"));
@@ -745,4 +786,68 @@ mod tests {
745 }; 786 };
746 assert!(config.archive_config().is_err()); 787 assert!(config.archive_config().is_err());
747 } 788 }
789
790 #[test]
791 fn test_archive_read_only_defaults() {
792 // Default: false when no archive mode
793 let config = Config::for_testing();
794 assert_eq!(config.archive_config().unwrap().read_only, false);
795
796 // Default: true when archive_all is set
797 let config = Config {
798 archive_all: true,
799 ..Config::for_testing()
800 };
801 assert_eq!(config.archive_config().unwrap().read_only, true);
802
803 // Default: true when archive_whitelist is set
804 let keys = Keys::generate();
805 let test_npub = keys.public_key().to_bech32().unwrap();
806 let config = Config {
807 archive_whitelist: test_npub,
808 ..Config::for_testing()
809 };
810 assert_eq!(config.archive_config().unwrap().read_only, true);
811 }
812
813 #[test]
814 fn test_archive_read_only_explicit() {
815 // Explicit true with archive_all
816 let config = Config {
817 archive_all: true,
818 archive_read_only: Some(true),
819 ..Config::for_testing()
820 };
821 assert_eq!(config.archive_config().unwrap().read_only, true);
822
823 // Explicit false with archive_all (unusual but allowed)
824 let config = Config {
825 archive_all: true,
826 archive_read_only: Some(false),
827 ..Config::for_testing()
828 };
829 assert_eq!(config.archive_config().unwrap().read_only, false);
830
831 // Explicit false without archive mode
832 let config = Config {
833 archive_read_only: Some(false),
834 ..Config::for_testing()
835 };
836 assert_eq!(config.archive_config().unwrap().read_only, false);
837 }
838
839 #[test]
840 fn test_archive_read_only_error() {
841 // Error: true without archive mode
842 let config = Config {
843 archive_read_only: Some(true),
844 ..Config::for_testing()
845 };
846 assert!(config.archive_config().is_err());
847 assert!(config
848 .archive_config()
849 .unwrap_err()
850 .to_string()
851 .contains("requires either"));
852 }
748} 853}
diff --git a/src/http/nip11.rs b/src/http/nip11.rs
index b756d9c..71cadb1 100644
--- a/src/http/nip11.rs
+++ b/src/http/nip11.rs
@@ -56,6 +56,41 @@ pub struct RelayInformationDocument {
56impl RelayInformationDocument { 56impl RelayInformationDocument {
57 /// Create NIP-11 relay information document from configuration 57 /// Create NIP-11 relay information document from configuration
58 pub fn from_config(config: &Config) -> Self { 58 pub fn from_config(config: &Config) -> Self {
59 // Determine if archive mode is enabled
60 let archive_config = config.archive_config().ok();
61 let archive_enabled = archive_config
62 .as_ref()
63 .map(|ac| ac.enabled())
64 .unwrap_or(false);
65 let archive_read_only = archive_config
66 .as_ref()
67 .map(|ac| ac.read_only)
68 .unwrap_or(false);
69
70 // Build supported_grasps list
71 let mut supported_grasps = vec!["GRASP-01".to_string()];
72 if archive_enabled {
73 supported_grasps.push("GRASP-05".to_string());
74 }
75 supported_grasps.push("GRASP-02".to_string());
76
77 // Build curation field for archive read-only mode
78 let curation = if archive_read_only {
79 if let Some(ref ac) = archive_config {
80 if ac.archive_all {
81 Some("Read-only sync of all repositories found on network".to_string())
82 } else if !ac.whitelist.is_empty() {
83 Some("Read-only sync of whitelisted repositories and maintainers".to_string())
84 } else {
85 None
86 }
87 } else {
88 None
89 }
90 } else {
91 None
92 };
93
59 Self { 94 Self {
60 name: config.relay_name(), 95 name: config.relay_name(),
61 description: config.relay_description.clone(), 96 description: config.relay_description.clone(),
@@ -75,9 +110,9 @@ impl RelayInformationDocument {
75 icon: Some(format!("https://{}/icon.png", config.domain)), 110 icon: Some(format!("https://{}/icon.png", config.domain)),
76 111
77 // GRASP Extensions 112 // GRASP Extensions
78 supported_grasps: vec!["GRASP-01".to_string(), "GRASP-02".to_string()], 113 supported_grasps,
79 repo_acceptance_criteria: "None".to_string(), 114 repo_acceptance_criteria: "None".to_string(),
80 curation: None, // Not a curated relay - only SPAM prevention via GRASP-01 policy 115 curation,
81 } 116 }
82 } 117 }
83 118
@@ -90,6 +125,7 @@ impl RelayInformationDocument {
90#[cfg(test)] 125#[cfg(test)]
91mod tests { 126mod tests {
92 use super::*; 127 use super::*;
128 use nostr_sdk::nips::nip19::ToBech32;
93 129
94 #[test] 130 #[test]
95 fn test_relay_information_document_structure() { 131 fn test_relay_information_document_structure() {
@@ -112,6 +148,7 @@ mod tests {
112 assert!(doc.supported_nips.contains(&11)); 148 assert!(doc.supported_nips.contains(&11));
113 assert!(doc.supported_nips.contains(&34)); 149 assert!(doc.supported_nips.contains(&34));
114 assert!(doc.supported_nips.contains(&77)); 150 assert!(doc.supported_nips.contains(&77));
151 // Without archive mode, only GRASP-01 and GRASP-02
115 assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]); 152 assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
116 assert!(doc.repo_acceptance_criteria.contains("None")); 153 assert!(doc.repo_acceptance_criteria.contains("None"));
117 assert!(doc.curation.is_none()); 154 assert!(doc.curation.is_none());
@@ -147,4 +184,50 @@ mod tests {
147 assert_eq!(parsed["supported_grasps"][1], "GRASP-02"); 184 assert_eq!(parsed["supported_grasps"][1], "GRASP-02");
148 assert_eq!(parsed["icon"], "https://relay.example.com/icon.png"); 185 assert_eq!(parsed["icon"], "https://relay.example.com/icon.png");
149 } 186 }
187
188 #[test]
189 fn test_nip11_with_archive_mode() {
190 let mut config = Config::for_testing();
191 config.domain = "relay.example.com".to_string();
192 config.relay_name_override = Some("Archive Relay".to_string());
193 config.archive_all = true;
194 config.archive_read_only = Some(true);
195
196 let doc = RelayInformationDocument::from_config(&config);
197
198 // Archive mode enabled: should include GRASP-05
199 assert_eq!(
200 doc.supported_grasps,
201 vec!["GRASP-01", "GRASP-05", "GRASP-02"]
202 );
203 // Archive read-only: should have curation field
204 assert!(doc.curation.is_some());
205 assert!(doc
206 .curation
207 .unwrap()
208 .contains("Read-only sync of all repositories"));
209 }
210
211 #[test]
212 fn test_nip11_with_whitelist_archive() {
213 let keys = nostr_sdk::Keys::generate();
214 let test_npub = keys.public_key().to_bech32().unwrap();
215 let mut config = Config::for_testing();
216 config.domain = "relay.example.com".to_string();
217 config.archive_whitelist = format!("{},bitcoin-core", test_npub);
218
219 let doc = RelayInformationDocument::from_config(&config);
220
221 // Archive whitelist enabled: should include GRASP-05
222 assert_eq!(
223 doc.supported_grasps,
224 vec!["GRASP-01", "GRASP-05", "GRASP-02"]
225 );
226 // Archive read-only defaults to true: should have curation field
227 assert!(doc.curation.is_some());
228 assert!(doc
229 .curation
230 .unwrap()
231 .contains("Read-only sync of whitelisted"));
232 }
150} 233}
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}