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 21:06:39 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-12 21:21:52 +0000
commit82b56c37b26a2fac1a294873e539b19b9325dca6 (patch)
tree07800949230f13f91fec2eebbd94b8fbb00dd83f
parenta12927181c571fc1641772ad44dd4c6a4ab209d9 (diff)
feat(config): add repository whitelist for curated GRASP-01 acceptance
Adds NGIT_REPOSITORY_WHITELIST option for curated relay operation that accepts only whitelisted repositories while maintaining GRASP-01 compliance (announcements must list the service). This differs from archive whitelist which enables GRASP-05 mode and doesn't require service listing. Key features: - Supports three whitelist formats: npub, npub/identifier, identifier - Enforces mutual exclusivity with archive read-only mode - Updates NIP-11 curation field when whitelist is enabled - Maintains GRASP-01 compliance (doesn't add GRASP-05 support) Configuration synced across all four sources: src/config.rs, docs/reference/configuration.md, nix/module.nix, and .env.example as required by AGENTS.md.
-rw-r--r--.env.example27
-rw-r--r--README.md1
-rw-r--r--docs/reference/configuration.md99
-rw-r--r--nix/module.nix15
-rw-r--r--src/config.rs210
-rw-r--r--src/http/nip11.rs65
-rw-r--r--src/nostr/builder.rs48
-rw-r--r--src/nostr/events.rs281
-rw-r--r--src/nostr/policy/announcement.rs14
9 files changed, 601 insertions, 159 deletions
diff --git a/.env.example b/.env.example
index cb797a8..0789b28 100644
--- a/.env.example
+++ b/.env.example
@@ -202,4 +202,29 @@
202# CLI: --archive-read-only 202# CLI: --archive-read-only
203# Default: true if NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set, false otherwise 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 204# Note: Setting to true without archive config causes startup error
205# NGIT_ARCHIVE_READ_ONLY= \ No newline at end of file 205# Note: Cannot be used with NGIT_REPOSITORY_WHITELIST (mutually exclusive)
206# NGIT_ARCHIVE_READ_ONLY=
207
208# ============================================================================
209# REPOSITORY WHITELIST
210# ============================================================================
211
212# Whitelist specific repos/pubkeys/identifiers for GRASP-01 acceptance
213# Comma-separated list supporting three formats (same as archive whitelist):
214# <npub> - Accept all repos from this pubkey (if they list our service)
215# <npub>/<identifier> - Accept specific repo (if it lists our service)
216# <identifier> - Accept repos with this identifier (if they list our service)
217#
218# Difference from archive whitelist:
219# - Repository whitelist: Announcements MUST list our service AND match whitelist
220# - Archive whitelist: Announcements don't need to list our service, just match whitelist
221#
222# CLI: --repository-whitelist <list>
223# Default: (empty - all repos listing our service are accepted)
224# Examples:
225# NGIT_REPOSITORY_WHITELIST=npub1alice...
226# NGIT_REPOSITORY_WHITELIST=npub1alice...,npub1bob.../linux
227# NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
228# Note: Cannot be used with NGIT_ARCHIVE_READ_ONLY=true (mutually exclusive)
229# Note: When set, NIP-11 curation field will indicate curated repository acceptance
230# NGIT_REPOSITORY_WHITELIST= \ No newline at end of file
diff --git a/README.md b/README.md
index ba6dfa2..c9d1066 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15
36- **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself 36- **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself
37- **Integrated Authorization**: Push validation happens inline during the Git receive-pack operation 37- **Integrated Authorization**: Push validation happens inline during the Git receive-pack operation
38- **GRASP-01 Compliant**: Core service requirements for Git hosting with Nostr authorization 38- **GRASP-01 Compliant**: Core service requirements for Git hosting with Nostr authorization
39 - **Repository Whitelist**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode)
39- **GRASP-02 Proactive Sync**: Sophisticated relay-to-relay event and git data synchronization 40- **GRASP-02 Proactive Sync**: Sophisticated relay-to-relay event and git data synchronization
40 - **NIP-77 Negentropy**: Efficient set reconciliation with automatic fallback to REQ+EOSE 41 - **NIP-77 Negentropy**: Efficient set reconciliation with automatic fallback to REQ+EOSE
41 - **Live & Historic Sync**: Real-time event streaming plus catch-up for past events 42 - **Live & Historic Sync**: Real-time event streaming plus catch-up for past events
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 4692600..1c62911 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -617,6 +617,13 @@ NGIT_ARCHIVE_ALL=false
617NGIT_ARCHIVE_WHITELIST= 617NGIT_ARCHIVE_WHITELIST=
618# → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either 618# → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either
619# NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" 619# NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set"
620
621# ERROR: Cannot use repository whitelist with archive read-only
622NGIT_ARCHIVE_READ_ONLY=true
623NGIT_ARCHIVE_WHITELIST=npub1alice...
624NGIT_REPOSITORY_WHITELIST=npub1bob...
625# → Server fails to start: "NGIT_REPOSITORY_WHITELIST cannot be used with
626# NGIT_ARCHIVE_READ_ONLY=true"
620``` 627```
621 628
622**NIP-11 Impact:** 629**NIP-11 Impact:**
@@ -645,6 +652,98 @@ NGIT_ARCHIVE_READ_ONLY=false
645 652
646--- 653---
647 654
655### Repository Whitelist
656
657#### `NGIT_REPOSITORY_WHITELIST`
658
659**Description:** Whitelist specific repositories/pubkeys/identifiers for GRASP-01 acceptance
660**Type:** Comma-separated list
661**Default:** Empty (all repos listing our service are accepted)
662**Required:** No
663
664**Format:** Same as `NGIT_ARCHIVE_WHITELIST`:
665- `npub1...` - Accept all repos from this pubkey (if they list our service)
666- `npub1.../identifier` - Accept specific repo (if it lists our service)
667- `identifier` - Accept repos with this identifier (if they list our service)
668
669**Difference from Archive Whitelist:**
670- **Repository whitelist**: Announcements **MUST** list our service **AND** match whitelist
671- **Archive whitelist**: Announcements don't need to list our service, just match whitelist
672
673**Examples:**
674
675```bash
676# Accept only repos from specific pubkey (that list our service)
677NGIT_REPOSITORY_WHITELIST=npub1alice23
678
679# Accept specific repos only
680NGIT_REPOSITORY_WHITELIST=npub1alice23/linux,npub1bob23/bitcoin-core
681
682# Accept repos with specific identifiers
683NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
684
685# Combined whitelist
686NGIT_REPOSITORY_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core
687```
688
689**Behavior:**
690
691- When set:
692 - Announcements **must** list our service in both `clone` and `relays` tags (GRASP-01 requirement)
693 - Announcements **must** match the whitelist (pubkey, repo, or identifier)
694 - NIP-11 `curation` field set to: `"Accepts only whitelisted repositories and maintainers that list this service"`
695- When empty (default):
696 - All announcements listing our service are accepted (standard GRASP-01 behavior)
697
698**Error Conditions:**
699
700```bash
701# ERROR: Cannot use with archive read-only mode
702NGIT_ARCHIVE_READ_ONLY=true
703NGIT_ARCHIVE_WHITELIST=npub1archive...
704NGIT_REPOSITORY_WHITELIST=npub1bob...
705# → Server fails to start: "NGIT_REPOSITORY_WHITELIST cannot be used with
706# NGIT_ARCHIVE_READ_ONLY=true. Either set NGIT_ARCHIVE_READ_ONLY=false
707# or use NGIT_ARCHIVE_WHITELIST instead"
708```
709
710**NIP-11 Impact:**
711
712When `NGIT_REPOSITORY_WHITELIST` is set:
713- `curation`: `"Accepts only whitelisted repositories and maintainers that list this service"`
714- `supported_grasps`: Does **not** include `GRASP-05` (still GRASP-01 compliant)
715
716**Use Cases:**
717
718```bash
719# Curated relay for specific projects (GRASP-01 mode)
720NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
721
722# Personal relay for self and trusted collaborators
723NGIT_REPOSITORY_WHITELIST=npub1me...,npub1alice...,npub1bob...
724
725# Project-specific relay (e.g., Rust ecosystem)
726NGIT_REPOSITORY_WHITELIST=rust,cargo,rustc,tokio,serde
727
728# Hybrid: specific projects AND specific maintainer's repos
729NGIT_REPOSITORY_WHITELIST=bitcoin-core,npub1alice...
730```
731
732**Comparison Table:**
733
734| Configuration | Lists Service? | Matches Whitelist? | Result |
735|---------------|----------------|-------------------|---------|
736| No whitelist | Yes | N/A | ✅ Accept (GRASP-01) |
737| No whitelist | No | N/A | ❌ Reject |
738| Repository whitelist | Yes | Yes | ✅ Accept (GRASP-01) |
739| Repository whitelist | Yes | No | ❌ Reject (not whitelisted) |
740| Repository whitelist | No | Yes | ❌ Reject (doesn't list service) |
741| Archive whitelist (read-only=true) | No | Yes | ✅ Accept (GRASP-05) |
742| Archive whitelist (read-only=false) | Yes | N/A | ✅ Accept (GRASP-01) |
743| Archive whitelist (read-only=false) | No | Yes | ✅ Accept (GRASP-05) |
744
745---
746
648### Logging Configuration 747### Logging Configuration
649 748
650#### `RUST_LOG` 749#### `RUST_LOG`
diff --git a/nix/module.nix b/nix/module.nix
index 516fb04..d5dfd88 100644
--- a/nix/module.nix
+++ b/nix/module.nix
@@ -207,6 +207,20 @@ let
207 - Repository announcements not listing this service are accepted per whitelist/archive-all 207 - Repository announcements not listing this service are accepted per whitelist/archive-all
208 Default: true if archiveAll or archiveWhitelist is set, false otherwise 208 Default: true if archiveAll or archiveWhitelist is set, false otherwise
209 Note: Setting to true without archive config causes startup error 209 Note: Setting to true without archive config causes startup error
210 Note: Cannot be used with repositoryWhitelist (mutually exclusive)
211 '';
212 };
213
214 repositoryWhitelist = mkOption {
215 type = types.listOf types.str;
216 default = [ ];
217 example = [ "npub1alice..." "npub1bob.../linux" "bitcoin-core" ];
218 description = ''
219 Repository whitelist for GRASP-01 acceptance.
220 Announcements must BOTH list our service AND match this whitelist.
221 Formats: <npub>, <npub>/<identifier>, <identifier>
222 Cannot be used with archiveReadOnly=true (mutually exclusive)
223 When set, NIP-11 curation field indicates curated repository acceptance
210 ''; 224 '';
211 }; 225 };
212 226
@@ -252,6 +266,7 @@ let
252 toString cfg.naughtyListExpirationHours; 266 toString cfg.naughtyListExpirationHours;
253 NGIT_ARCHIVE_ALL = toString cfg.archiveAll; 267 NGIT_ARCHIVE_ALL = toString cfg.archiveAll;
254 NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; 268 NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist;
269 NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist;
255 RUST_LOG = cfg.logLevel; 270 RUST_LOG = cfg.logLevel;
256 } // optionalAttrs (cfg.relayName != null) { 271 } // optionalAttrs (cfg.relayName != null) {
257 NGIT_RELAY_NAME = cfg.relayName; 272 NGIT_RELAY_NAME = cfg.relayName;
diff --git a/src/config.rs b/src/config.rs
index d9917a3..8a9eb4d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -5,21 +5,21 @@ use serde::{Deserialize, Serialize};
5use std::fs; 5use std::fs;
6use std::path::PathBuf; 6use std::path::PathBuf;
7 7
8/// GRASP-05 Archive whitelist entry 8/// Whitelist entry for repository/archive filtering
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "lowercase")] 10#[serde(rename_all = "lowercase")]
11pub enum ArchiveWhitelistEntry { 11pub enum WhitelistEntry {
12 /// Archive all repos from this pubkey: "npub1..." 12 /// All repos from this pubkey: "npub1..."
13 Pubkey(String), 13 Pubkey(String),
14 14
15 /// Archive specific repo: "npub1.../identifier" 15 /// Specific repo: "npub1.../identifier"
16 Repository { npub: String, identifier: String }, 16 Repository { npub: String, identifier: String },
17 17
18 /// Archive any repo with this identifier: "identifier" 18 /// Any repo with this identifier: "identifier"
19 Identifier(String), 19 Identifier(String),
20} 20}
21 21
22impl ArchiveWhitelistEntry { 22impl WhitelistEntry {
23 /// Parse a whitelist entry from string 23 /// Parse a whitelist entry from string
24 /// 24 ///
25 /// Formats: 25 /// Formats:
@@ -83,6 +83,20 @@ impl ArchiveWhitelistEntry {
83 Self::Identifier(i) => identifier == i, 83 Self::Identifier(i) => identifier == i,
84 } 84 }
85 } 85 }
86
87 /// Parse whitelist from comma-separated string
88 pub fn parse_whitelist(input: &str) -> Result<Vec<Self>> {
89 if input.trim().is_empty() {
90 return Ok(Vec::new());
91 }
92
93 input
94 .split(',')
95 .map(|s| s.trim())
96 .filter(|s| !s.is_empty())
97 .map(Self::parse)
98 .collect()
99 }
86} 100}
87 101
88/// GRASP-05 Archive mode configuration 102/// GRASP-05 Archive mode configuration
@@ -97,7 +111,7 @@ pub struct ArchiveConfig {
97 /// Whitelist entries for selective archiving 111 /// Whitelist entries for selective archiving
98 /// 112 ///
99 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). 113 /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
100 pub whitelist: Vec<ArchiveWhitelistEntry>, 114 pub whitelist: Vec<WhitelistEntry>,
101 115
102 /// Read-only archive mode: relay is a read-only sync of archived repositories 116 /// Read-only archive mode: relay is a read-only sync of archived repositories
103 /// 117 ///
@@ -127,28 +141,47 @@ impl ArchiveConfig {
127 .iter() 141 .iter()
128 .any(|entry| entry.matches(npub, identifier)) 142 .any(|entry| entry.matches(npub, identifier))
129 } 143 }
144}
130 145
131 /// Parse archive whitelist from comma-separated string 146impl Default for ArchiveConfig {
132 pub fn parse_whitelist(input: &str) -> Result<Vec<ArchiveWhitelistEntry>> { 147 fn default() -> Self {
133 if input.trim().is_empty() { 148 Self {
134 return Ok(Vec::new()); 149 archive_all: false,
150 whitelist: Vec::new(),
151 read_only: false,
135 } 152 }
153 }
154}
136 155
137 input 156/// Repository whitelist configuration
138 .split(',') 157#[derive(Debug, Clone, Serialize, Deserialize)]
139 .map(|s| s.trim()) 158pub struct RepositoryConfig {
140 .filter(|s| !s.is_empty()) 159 /// Whitelist entries for selective repository acceptance
141 .map(ArchiveWhitelistEntry::parse) 160 ///
142 .collect() 161 /// If empty, all repositories listing the service are accepted (GRASP-01 mode).
162 pub whitelist: Vec<WhitelistEntry>,
163}
164
165impl RepositoryConfig {
166 /// Check if repository whitelist is enabled (non-empty whitelist)
167 pub fn enabled(&self) -> bool {
168 !self.whitelist.is_empty()
169 }
170
171 /// Check if an announcement matches the repository whitelist
172 ///
173 /// Returns true if announcement matches any whitelist entry
174 pub fn matches(&self, npub: &str, identifier: &str) -> bool {
175 self.whitelist
176 .iter()
177 .any(|entry| entry.matches(npub, identifier))
143 } 178 }
144} 179}
145 180
146impl Default for ArchiveConfig { 181impl Default for RepositoryConfig {
147 fn default() -> Self { 182 fn default() -> Self {
148 Self { 183 Self {
149 archive_all: false,
150 whitelist: Vec::new(), 184 whitelist: Vec::new(),
151 read_only: false,
152 } 185 }
153 } 186 }
154} 187}
@@ -325,6 +358,12 @@ pub struct Config {
325 /// Throws error if set to true without archive_all or archive_whitelist 358 /// Throws error if set to true without archive_all or archive_whitelist
326 #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] 359 #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")]
327 pub archive_read_only: Option<bool>, 360 pub archive_read_only: Option<bool>,
361
362 /// Repository whitelist: comma-separated list of npub/identifier/npub/identifier entries
363 /// Formats: "npub1...", "npub1.../identifier", "identifier"
364 /// When set, only announcements matching the whitelist AND listing the service are accepted
365 #[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")]
366 pub repository_whitelist: String,
328} 367}
329 368
330impl Config { 369impl Config {
@@ -430,7 +469,7 @@ impl Config {
430 /// Read-only mode defaults to true if archive mode is enabled, false otherwise. 469 /// 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. 470 /// Throws error if explicitly set to true without archive mode enabled.
432 pub fn archive_config(&self) -> Result<ArchiveConfig> { 471 pub fn archive_config(&self) -> Result<ArchiveConfig> {
433 let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; 472 let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist)?;
434 let archive_enabled = self.archive_all || !whitelist.is_empty(); 473 let archive_enabled = self.archive_all || !whitelist.is_empty();
435 474
436 let read_only = match self.archive_read_only { 475 let read_only = match self.archive_read_only {
@@ -456,6 +495,28 @@ impl Config {
456 }) 495 })
457 } 496 }
458 497
498 /// Get parsed repository whitelist configuration
499 ///
500 /// Throws error if repository_whitelist is set together with archive_read_only=true
501 pub fn repository_config(&self) -> Result<RepositoryConfig> {
502 let whitelist = WhitelistEntry::parse_whitelist(&self.repository_whitelist)?;
503
504 // Validate incompatible configurations
505 if !whitelist.is_empty() {
506 let archive_config = self.archive_config()?;
507 if archive_config.read_only {
508 return Err(anyhow!(
509 "NGIT_REPOSITORY_WHITELIST cannot be used with NGIT_ARCHIVE_READ_ONLY=true. \
510 Archive read-only mode rejects announcements that don't match the archive whitelist, \
511 regardless of service listing. Either set NGIT_ARCHIVE_READ_ONLY=false or use \
512 NGIT_ARCHIVE_WHITELIST instead of NGIT_REPOSITORY_WHITELIST."
513 ));
514 }
515 }
516
517 Ok(RepositoryConfig { whitelist })
518 }
519
459 /// Create config for testing 520 /// Create config for testing
460 #[cfg(test)] 521 #[cfg(test)]
461 pub fn for_testing() -> Self { 522 pub fn for_testing() -> Self {
@@ -489,6 +550,7 @@ impl Config {
489 archive_all: false, 550 archive_all: false,
490 archive_whitelist: String::new(), 551 archive_whitelist: String::new(),
491 archive_read_only: None, 552 archive_read_only: None,
553 repository_whitelist: String::new(),
492 } 554 }
493 } 555 }
494} 556}
@@ -629,9 +691,9 @@ mod tests {
629 // Generate a valid test npub 691 // Generate a valid test npub
630 let keys = Keys::generate(); 692 let keys = Keys::generate();
631 let test_npub = keys.public_key().to_bech32().unwrap(); 693 let test_npub = keys.public_key().to_bech32().unwrap();
632 let entry = ArchiveWhitelistEntry::parse(&test_npub).unwrap(); 694 let entry = WhitelistEntry::parse(&test_npub).unwrap();
633 assert!(matches!(entry, ArchiveWhitelistEntry::Pubkey(_))); 695 assert!(matches!(entry, WhitelistEntry::Pubkey(_)));
634 if let ArchiveWhitelistEntry::Pubkey(npub) = entry { 696 if let WhitelistEntry::Pubkey(npub) = entry {
635 assert_eq!(npub, test_npub); 697 assert_eq!(npub, test_npub);
636 } 698 }
637 } 699 }
@@ -640,9 +702,9 @@ mod tests {
640 fn test_parse_whitelist_entry_repository() { 702 fn test_parse_whitelist_entry_repository() {
641 let keys = Keys::generate(); 703 let keys = Keys::generate();
642 let test_npub = keys.public_key().to_bech32().unwrap(); 704 let test_npub = keys.public_key().to_bech32().unwrap();
643 let entry = ArchiveWhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap(); 705 let entry = WhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap();
644 assert!(matches!(entry, ArchiveWhitelistEntry::Repository { .. })); 706 assert!(matches!(entry, WhitelistEntry::Repository { .. }));
645 if let ArchiveWhitelistEntry::Repository { npub, identifier } = entry { 707 if let WhitelistEntry::Repository { npub, identifier } = entry {
646 assert_eq!(npub, test_npub); 708 assert_eq!(npub, test_npub);
647 assert_eq!(identifier, "linux"); 709 assert_eq!(identifier, "linux");
648 } 710 }
@@ -650,16 +712,16 @@ mod tests {
650 712
651 #[test] 713 #[test]
652 fn test_parse_whitelist_entry_identifier() { 714 fn test_parse_whitelist_entry_identifier() {
653 let entry = ArchiveWhitelistEntry::parse("bitcoin-core").unwrap(); 715 let entry = WhitelistEntry::parse("bitcoin-core").unwrap();
654 assert!(matches!(entry, ArchiveWhitelistEntry::Identifier(_))); 716 assert!(matches!(entry, WhitelistEntry::Identifier(_)));
655 if let ArchiveWhitelistEntry::Identifier(id) = entry { 717 if let WhitelistEntry::Identifier(id) = entry {
656 assert_eq!(id, "bitcoin-core"); 718 assert_eq!(id, "bitcoin-core");
657 } 719 }
658 } 720 }
659 721
660 #[test] 722 #[test]
661 fn test_parse_whitelist_entry_invalid_npub() { 723 fn test_parse_whitelist_entry_invalid_npub() {
662 let result = ArchiveWhitelistEntry::parse("npub1invalid"); 724 let result = WhitelistEntry::parse("npub1invalid");
663 assert!(result.is_err()); 725 assert!(result.is_err());
664 } 726 }
665 727
@@ -667,7 +729,7 @@ mod tests {
667 fn test_whitelist_entry_matches() { 729 fn test_whitelist_entry_matches() {
668 let keys = Keys::generate(); 730 let keys = Keys::generate();
669 let test_npub = keys.public_key().to_bech32().unwrap(); 731 let test_npub = keys.public_key().to_bech32().unwrap();
670 let entry = ArchiveWhitelistEntry::Pubkey(test_npub.clone()); 732 let entry = WhitelistEntry::Pubkey(test_npub.clone());
671 assert!(entry.matches(&test_npub, "any-identifier")); 733 assert!(entry.matches(&test_npub, "any-identifier"));
672 assert!(!entry.matches("npub1different", "any-identifier")); 734 assert!(!entry.matches("npub1different", "any-identifier"));
673 } 735 }
@@ -676,7 +738,7 @@ mod tests {
676 fn test_whitelist_entry_matches_repository() { 738 fn test_whitelist_entry_matches_repository() {
677 let keys = Keys::generate(); 739 let keys = Keys::generate();
678 let test_npub = keys.public_key().to_bech32().unwrap(); 740 let test_npub = keys.public_key().to_bech32().unwrap();
679 let entry = ArchiveWhitelistEntry::Repository { 741 let entry = WhitelistEntry::Repository {
680 npub: test_npub.clone(), 742 npub: test_npub.clone(),
681 identifier: "linux".to_string(), 743 identifier: "linux".to_string(),
682 }; 744 };
@@ -687,7 +749,7 @@ mod tests {
687 749
688 #[test] 750 #[test]
689 fn test_whitelist_entry_matches_identifier() { 751 fn test_whitelist_entry_matches_identifier() {
690 let entry = ArchiveWhitelistEntry::Identifier("bitcoin-core".to_string()); 752 let entry = WhitelistEntry::Identifier("bitcoin-core".to_string());
691 assert!(entry.matches("npub1alice", "bitcoin-core")); 753 assert!(entry.matches("npub1alice", "bitcoin-core"));
692 assert!(entry.matches("npub1bob", "bitcoin-core")); 754 assert!(entry.matches("npub1bob", "bitcoin-core"));
693 assert!(!entry.matches("npub1alice", "other-repo")); 755 assert!(!entry.matches("npub1alice", "other-repo"));
@@ -707,7 +769,7 @@ mod tests {
707 769
708 let config = ArchiveConfig { 770 let config = ArchiveConfig {
709 archive_all: false, 771 archive_all: false,
710 whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], 772 whitelist: vec![WhitelistEntry::Identifier("test".into())],
711 read_only: true, 773 read_only: true,
712 }; 774 };
713 assert!(config.enabled()); 775 assert!(config.enabled());
@@ -720,8 +782,8 @@ mod tests {
720 let config = ArchiveConfig { 782 let config = ArchiveConfig {
721 archive_all: false, 783 archive_all: false,
722 whitelist: vec![ 784 whitelist: vec![
723 ArchiveWhitelistEntry::Pubkey(test_npub.clone()), 785 WhitelistEntry::Pubkey(test_npub.clone()),
724 ArchiveWhitelistEntry::Identifier("bitcoin-core".into()), 786 WhitelistEntry::Identifier("bitcoin-core".into()),
725 ], 787 ],
726 read_only: false, 788 read_only: false,
727 }; 789 };
@@ -745,10 +807,10 @@ mod tests {
745 807
746 #[test] 808 #[test]
747 fn test_parse_whitelist_empty() { 809 fn test_parse_whitelist_empty() {
748 let whitelist = ArchiveConfig::parse_whitelist("").unwrap(); 810 let whitelist = WhitelistEntry::parse_whitelist("").unwrap();
749 assert!(whitelist.is_empty()); 811 assert!(whitelist.is_empty());
750 812
751 let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap(); 813 let whitelist = WhitelistEntry::parse_whitelist(" ").unwrap();
752 assert!(whitelist.is_empty()); 814 assert!(whitelist.is_empty());
753 } 815 }
754 816
@@ -758,7 +820,7 @@ mod tests {
758 let keys2 = Keys::generate(); 820 let keys2 = Keys::generate();
759 let test_npub1 = keys1.public_key().to_bech32().unwrap(); 821 let test_npub1 = keys1.public_key().to_bech32().unwrap();
760 let test_npub2 = keys2.public_key().to_bech32().unwrap(); 822 let test_npub2 = keys2.public_key().to_bech32().unwrap();
761 let whitelist = ArchiveConfig::parse_whitelist(&format!( 823 let whitelist = WhitelistEntry::parse_whitelist(&format!(
762 "{},bitcoin-core,{}/linux", 824 "{},bitcoin-core,{}/linux",
763 test_npub1, test_npub2 825 test_npub1, test_npub2
764 )) 826 ))
@@ -850,4 +912,72 @@ mod tests {
850 .to_string() 912 .to_string()
851 .contains("requires either")); 913 .contains("requires either"));
852 } 914 }
915
916 #[test]
917 fn test_repository_whitelist_parsing() {
918 let keys = Keys::generate();
919 let test_npub = keys.public_key().to_bech32().unwrap();
920 let config = Config {
921 repository_whitelist: format!("{},bitcoin-core", test_npub),
922 ..Config::for_testing()
923 };
924 let repo_config = config.repository_config().unwrap();
925 assert_eq!(repo_config.whitelist.len(), 2);
926 assert!(repo_config.enabled());
927 }
928
929 #[test]
930 fn test_repository_whitelist_empty() {
931 let config = Config::for_testing();
932 let repo_config = config.repository_config().unwrap();
933 assert!(repo_config.whitelist.is_empty());
934 assert!(!repo_config.enabled());
935 }
936
937 #[test]
938 fn test_repository_whitelist_incompatible_with_archive_read_only() {
939 let keys = Keys::generate();
940 let test_npub = keys.public_key().to_bech32().unwrap();
941 let config = Config {
942 archive_all: true,
943 archive_read_only: Some(true),
944 repository_whitelist: test_npub,
945 ..Config::for_testing()
946 };
947 let result = config.repository_config();
948 assert!(result.is_err());
949 let err = result.unwrap_err().to_string();
950 assert!(err.contains("cannot be used with"));
951 assert!(err.contains("NGIT_ARCHIVE_READ_ONLY=true"));
952 }
953
954 #[test]
955 fn test_repository_whitelist_compatible_with_archive_read_only_false() {
956 let keys = Keys::generate();
957 let test_npub = keys.public_key().to_bech32().unwrap();
958 let config = Config {
959 archive_all: true,
960 archive_read_only: Some(false),
961 repository_whitelist: test_npub,
962 ..Config::for_testing()
963 };
964 // Should not error
965 assert!(config.repository_config().is_ok());
966 }
967
968 #[test]
969 fn test_repository_config_matches() {
970 let keys = Keys::generate();
971 let test_npub = keys.public_key().to_bech32().unwrap();
972 let config = RepositoryConfig {
973 whitelist: vec![
974 WhitelistEntry::Pubkey(test_npub.clone()),
975 WhitelistEntry::Identifier("bitcoin-core".into()),
976 ],
977 };
978
979 assert!(config.matches(&test_npub, "any-repo"));
980 assert!(config.matches("npub1bob", "bitcoin-core"));
981 assert!(!config.matches("npub1bob", "other-repo"));
982 }
853} 983}
diff --git a/src/http/nip11.rs b/src/http/nip11.rs
index 71cadb1..ff7b8df 100644
--- a/src/http/nip11.rs
+++ b/src/http/nip11.rs
@@ -74,8 +74,15 @@ impl RelayInformationDocument {
74 } 74 }
75 supported_grasps.push("GRASP-02".to_string()); 75 supported_grasps.push("GRASP-02".to_string());
76 76
77 // Build curation field for archive read-only mode 77 // Build curation field for archive read-only mode or repository whitelist
78 let repository_config = config.repository_config().ok();
79 let repository_whitelist_enabled = repository_config
80 .as_ref()
81 .map(|rc| rc.enabled())
82 .unwrap_or(false);
83
78 let curation = if archive_read_only { 84 let curation = if archive_read_only {
85 // Archive read-only mode (GRASP-05 only)
79 if let Some(ref ac) = archive_config { 86 if let Some(ref ac) = archive_config {
80 if ac.archive_all { 87 if ac.archive_all {
81 Some("Read-only sync of all repositories found on network".to_string()) 88 Some("Read-only sync of all repositories found on network".to_string())
@@ -87,6 +94,18 @@ impl RelayInformationDocument {
87 } else { 94 } else {
88 None 95 None
89 } 96 }
97 } else if archive_enabled && repository_whitelist_enabled {
98 // Both archive (non-read-only) AND repository whitelist enabled
99 Some(
100 "Accepts whitelisted repositories (with or without service listing) and whitelisted repositories that list this service"
101 .to_string(),
102 )
103 } else if repository_whitelist_enabled {
104 // Repository whitelist only
105 Some(
106 "Accepts only whitelisted repositories and maintainers that list this service"
107 .to_string(),
108 )
90 } else { 109 } else {
91 None 110 None
92 }; 111 };
@@ -230,4 +249,48 @@ mod tests {
230 .unwrap() 249 .unwrap()
231 .contains("Read-only sync of whitelisted")); 250 .contains("Read-only sync of whitelisted"));
232 } 251 }
252
253 #[test]
254 fn test_nip11_with_repository_whitelist() {
255 let keys = nostr_sdk::Keys::generate();
256 let test_npub = keys.public_key().to_bech32().unwrap();
257 let mut config = Config::for_testing();
258 config.domain = "relay.example.com".to_string();
259 config.repository_whitelist = format!("{},bitcoin-core", test_npub);
260
261 let doc = RelayInformationDocument::from_config(&config);
262
263 // Repository whitelist doesn't enable GRASP-05
264 assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
265 // Should have curation field for repository whitelist
266 assert!(doc.curation.is_some());
267 assert!(doc
268 .curation
269 .unwrap()
270 .contains("Accepts only whitelisted repositories"));
271 }
272
273 #[test]
274 fn test_nip11_with_archive_and_repository_whitelist() {
275 let keys = nostr_sdk::Keys::generate();
276 let test_npub = keys.public_key().to_bech32().unwrap();
277 let mut config = Config::for_testing();
278 config.domain = "relay.example.com".to_string();
279 config.archive_whitelist = "bitcoin-core".to_string();
280 config.archive_read_only = Some(false); // Non-read-only archive mode
281 config.repository_whitelist = test_npub;
282
283 let doc = RelayInformationDocument::from_config(&config);
284
285 // Should have GRASP-05 enabled due to archive whitelist
286 assert_eq!(
287 doc.supported_grasps,
288 vec!["GRASP-01", "GRASP-05", "GRASP-02"]
289 );
290 // Should have curation field reflecting BOTH archive and repository whitelist
291 assert!(doc.curation.is_some());
292 let curation = doc.curation.unwrap();
293 assert!(curation.contains("whitelisted repositories"));
294 assert!(curation.contains("with or without service listing"));
295 }
233} 296}
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 33f2fe5..10f7648 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -51,15 +51,14 @@ impl std::fmt::Debug for Nip34WritePolicy {
51 51
52impl Nip34WritePolicy { 52impl Nip34WritePolicy {
53 pub fn new( 53 pub fn new(
54 domain: impl Into<String>,
55 database: SharedDatabase, 54 database: SharedDatabase,
56 git_data_path: impl Into<std::path::PathBuf>, 55 git_data_path: impl Into<std::path::PathBuf>,
57 purgatory: std::sync::Arc<crate::purgatory::Purgatory>, 56 purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
58 archive_config: crate::config::ArchiveConfig, 57 config: crate::config::Config,
59 ) -> Self { 58 ) -> Self {
60 let ctx = PolicyContext::new(domain, database, git_data_path, purgatory); 59 let ctx = PolicyContext::new(&config.domain, database, git_data_path, purgatory);
61 Self { 60 Self {
62 announcement_policy: AnnouncementPolicy::new(ctx.clone(), archive_config), 61 announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()),
63 state_policy: StatePolicy::new(ctx.clone()), 62 state_policy: StatePolicy::new(ctx.clone()),
64 pr_event_policy: PrEventPolicy::new(ctx.clone()), 63 pr_event_policy: PrEventPolicy::new(ctx.clone()),
65 related_event_policy: RelatedEventPolicy::new(ctx.clone()), 64 related_event_policy: RelatedEventPolicy::new(ctx.clone()),
@@ -568,28 +567,31 @@ pub async fn create_relay(
568 // Clone Arc for the write policy so both relay and policy can access the database 567 // Clone Arc for the write policy so both relay and policy can access the database
569 let git_data_path = config.effective_git_data_path(); 568 let git_data_path = config.effective_git_data_path();
570 569
571 // Parse archive configuration 570 // Parse and log archive configuration
572 let archive_config = config 571 if let Ok(archive_config) = config.archive_config() {
573 .archive_config() 572 if archive_config.enabled() {
574 .map_err(|e| anyhow::anyhow!("Failed to parse archive configuration: {}", e))?; 573 tracing::info!(
575 574 "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}",
576 if archive_config.enabled() { 575 archive_config.archive_all,
577 tracing::info!( 576 archive_config.whitelist.len(),
578 "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}", 577 archive_config.read_only
579 archive_config.archive_all, 578 );
580 archive_config.whitelist.len(), 579 }
581 archive_config.read_only 580 }
582 ); 581
582 // Parse and log repository configuration
583 if let Ok(repository_config) = config.repository_config() {
584 if repository_config.enabled() {
585 tracing::info!(
586 "Repository whitelist enabled: whitelist_entries={}",
587 repository_config.whitelist.len()
588 );
589 }
583 } 590 }
584 591
585 // Create write policy with purgatory integration 592 // Create write policy with purgatory integration
586 let write_policy = Nip34WritePolicy::new( 593 let write_policy =
587 &config.domain, 594 Nip34WritePolicy::new(database.clone(), &git_data_path, purgatory, config.clone());
588 database.clone(),
589 &git_data_path,
590 purgatory,
591 archive_config,
592 );
593 595
594 let relay = LocalRelayBuilder::default() 596 let relay = LocalRelayBuilder::default()
595 .database(database.clone()) 597 .database(database.clone())
diff --git a/src/nostr/events.rs b/src/nostr/events.rs
index f83e00c..3ec075d 100644
--- a/src/nostr/events.rs
+++ b/src/nostr/events.rs
@@ -362,7 +362,7 @@ 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) - unless archive_read_only mode 365/// - Accept: Announcement lists our service (GRASP-01) AND matches repository whitelist (if enabled)
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///
@@ -370,11 +370,13 @@ impl RepositoryState {
370/// - ONLY accept announcements matching archive whitelist/all 370/// - ONLY accept announcements matching archive whitelist/all
371/// - REJECT announcements listing our service but not in whitelist (read-only sync mode) 371/// - REJECT announcements listing our service but not in whitelist (read-only sync mode)
372/// 372///
373/// When repository_whitelist is set:
374/// - Announcements must BOTH list our service AND match the repository whitelist
375///
373/// Note: AcceptMaintainer is NOT returned here (requires database access) 376/// Note: AcceptMaintainer is NOT returned here (requires database access)
374pub fn validate_announcement( 377pub fn validate_announcement(
375 event: &Event, 378 event: &Event,
376 domain: &str, 379 config: &crate::config::Config,
377 archive_config: &crate::config::ArchiveConfig,
378) -> crate::nostr::policy::AnnouncementResult { 380) -> crate::nostr::policy::AnnouncementResult {
379 use crate::nostr::policy::AnnouncementResult; 381 use crate::nostr::policy::AnnouncementResult;
380 382
@@ -398,12 +400,33 @@ pub fn validate_announcement(
398 Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), 400 Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)),
399 }; 401 };
400 402
401 // GRASP-01: Normal mode - accept if announcement lists our service 403 // Get archive and repository configs (fail-secure: reject on config errors)
402 if announcement.lists_service(domain) && !archive_config.read_only { 404 let archive_config = match config.archive_config() {
403 return AnnouncementResult::Accept; 405 Ok(c) => c,
404 } 406 Err(e) => return AnnouncementResult::Reject(format!("Config error: {}", e)),
407 };
408 let repository_config = match config.repository_config() {
409 Ok(c) => c,
410 Err(e) => return AnnouncementResult::Reject(format!("Config error: {}", e)),
411 };
405 412
406 let npub = announcement.owner_npub(); 413 let npub = announcement.owner_npub();
414 let lists_service = announcement.lists_service(&config.domain);
415
416 // GRASP-01: Normal mode - accept if announcement lists our service AND matches repository whitelist (if enabled)
417 if lists_service && !archive_config.read_only {
418 // Check repository whitelist if enabled
419 if repository_config.enabled() {
420 if !repository_config.matches(&npub, &announcement.identifier) {
421 return AnnouncementResult::Reject(format!(
422 "Announcement lists service but does not match repository whitelist. \
423 Repository {}/{} not in whitelist",
424 npub, announcement.identifier
425 ));
426 }
427 }
428 return AnnouncementResult::Accept;
429 }
407 430
408 // GRASP-05: Archive mode - accept if announcement matches whitelist 431 // GRASP-05: Archive mode - accept if announcement matches whitelist
409 if archive_config.matches(&npub, &announcement.identifier) { 432 if archive_config.matches(&npub, &announcement.identifier) {
@@ -561,7 +584,7 @@ mod tests {
561 584
562 #[test] 585 #[test]
563 fn test_validate_announcement_success() { 586 fn test_validate_announcement_success() {
564 use crate::config::ArchiveConfig; 587 use crate::config::Config;
565 use crate::nostr::policy::AnnouncementResult; 588 use crate::nostr::policy::AnnouncementResult;
566 589
567 let keys = create_test_keys(); 590 let keys = create_test_keys();
@@ -572,13 +595,17 @@ mod tests {
572 vec!["wss://gitnostr.com"], 595 vec!["wss://gitnostr.com"],
573 ); 596 );
574 597
575 let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); 598 let config = Config {
599 domain: "gitnostr.com".to_string(),
600 ..Config::for_testing()
601 };
602 let result = validate_announcement(&event, &config);
576 assert!(matches!(result, AnnouncementResult::Accept)); 603 assert!(matches!(result, AnnouncementResult::Accept));
577 } 604 }
578 605
579 #[test] 606 #[test]
580 fn test_validate_announcement_missing_clone() { 607 fn test_validate_announcement_missing_clone() {
581 use crate::config::ArchiveConfig; 608 use crate::config::Config;
582 use crate::nostr::policy::AnnouncementResult; 609 use crate::nostr::policy::AnnouncementResult;
583 610
584 let keys = create_test_keys(); 611 let keys = create_test_keys();
@@ -589,7 +616,11 @@ mod tests {
589 vec!["wss://gitnostr.com"], 616 vec!["wss://gitnostr.com"],
590 ); 617 );
591 618
592 let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); 619 let config = Config {
620 domain: "gitnostr.com".to_string(),
621 ..Config::for_testing()
622 };
623 let result = validate_announcement(&event, &config);
593 if let AnnouncementResult::Reject(reason) = result { 624 if let AnnouncementResult::Reject(reason) = result {
594 assert!(reason.contains("clone")); 625 assert!(reason.contains("clone"));
595 } else { 626 } else {
@@ -599,7 +630,7 @@ mod tests {
599 630
600 #[test] 631 #[test]
601 fn test_validate_announcement_missing_relay() { 632 fn test_validate_announcement_missing_relay() {
602 use crate::config::ArchiveConfig; 633 use crate::config::Config;
603 use crate::nostr::policy::AnnouncementResult; 634 use crate::nostr::policy::AnnouncementResult;
604 635
605 let keys = create_test_keys(); 636 let keys = create_test_keys();
@@ -610,7 +641,11 @@ mod tests {
610 vec![], // No relays 641 vec![], // No relays
611 ); 642 );
612 643
613 let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); 644 let config = Config {
645 domain: "gitnostr.com".to_string(),
646 ..Config::for_testing()
647 };
648 let result = validate_announcement(&event, &config);
614 if let AnnouncementResult::Reject(reason) = result { 649 if let AnnouncementResult::Reject(reason) = result {
615 assert!(reason.contains("relays")); 650 assert!(reason.contains("relays"));
616 } else { 651 } else {
@@ -620,7 +655,7 @@ mod tests {
620 655
621 #[test] 656 #[test]
622 fn test_validate_announcement_wrong_domain() { 657 fn test_validate_announcement_wrong_domain() {
623 use crate::config::ArchiveConfig; 658 use crate::config::Config;
624 use crate::nostr::policy::AnnouncementResult; 659 use crate::nostr::policy::AnnouncementResult;
625 660
626 let keys = create_test_keys(); 661 let keys = create_test_keys();
@@ -631,7 +666,11 @@ mod tests {
631 vec!["wss://other-service.com"], 666 vec!["wss://other-service.com"],
632 ); 667 );
633 668
634 let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default()); 669 let config = Config {
670 domain: "gitnostr.com".to_string(),
671 ..Config::for_testing()
672 };
673 let result = validate_announcement(&event, &config);
635 assert!(matches!(result, AnnouncementResult::Reject(_))); 674 assert!(matches!(result, AnnouncementResult::Reject(_)));
636 } 675 }
637 676
@@ -855,7 +894,7 @@ mod tests {
855 894
856 #[test] 895 #[test]
857 fn test_validate_announcement_with_trailing_slash_in_relay() { 896 fn test_validate_announcement_with_trailing_slash_in_relay() {
858 use crate::config::ArchiveConfig; 897 use crate::config::Config;
859 use crate::nostr::policy::AnnouncementResult; 898 use crate::nostr::policy::AnnouncementResult;
860 899
861 let keys = create_test_keys(); 900 let keys = create_test_keys();
@@ -867,14 +906,17 @@ mod tests {
867 ); 906 );
868 907
869 // Should accept despite trailing slash mismatch 908 // Should accept despite trailing slash mismatch
870 let result = 909 let config = Config {
871 validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); 910 domain: "git.shakespeare.diy".to_string(),
911 ..Config::for_testing()
912 };
913 let result = validate_announcement(&event, &config);
872 assert!(matches!(result, AnnouncementResult::Accept)); 914 assert!(matches!(result, AnnouncementResult::Accept));
873 } 915 }
874 916
875 #[test] 917 #[test]
876 fn test_validate_announcement_with_trailing_slash_in_clone_url() { 918 fn test_validate_announcement_with_trailing_slash_in_clone_url() {
877 use crate::config::ArchiveConfig; 919 use crate::config::Config;
878 use crate::nostr::policy::AnnouncementResult; 920 use crate::nostr::policy::AnnouncementResult;
879 921
880 let keys = create_test_keys(); 922 let keys = create_test_keys();
@@ -886,14 +928,17 @@ mod tests {
886 ); 928 );
887 929
888 // Should accept despite trailing slash mismatch 930 // Should accept despite trailing slash mismatch
889 let result = 931 let config = Config {
890 validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); 932 domain: "git.shakespeare.diy".to_string(),
933 ..Config::for_testing()
934 };
935 let result = validate_announcement(&event, &config);
891 assert!(matches!(result, AnnouncementResult::Accept)); 936 assert!(matches!(result, AnnouncementResult::Accept));
892 } 937 }
893 938
894 #[test] 939 #[test]
895 fn test_validate_announcement_with_trailing_slash_in_both() { 940 fn test_validate_announcement_with_trailing_slash_in_both() {
896 use crate::config::ArchiveConfig; 941 use crate::config::Config;
897 use crate::nostr::policy::AnnouncementResult; 942 use crate::nostr::policy::AnnouncementResult;
898 943
899 let keys = create_test_keys(); 944 let keys = create_test_keys();
@@ -905,14 +950,17 @@ mod tests {
905 ); 950 );
906 951
907 // Should accept with trailing slashes in both 952 // Should accept with trailing slashes in both
908 let result = 953 let config = Config {
909 validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default()); 954 domain: "git.shakespeare.diy".to_string(),
955 ..Config::for_testing()
956 };
957 let result = validate_announcement(&event, &config);
910 assert!(matches!(result, AnnouncementResult::Accept)); 958 assert!(matches!(result, AnnouncementResult::Accept));
911 } 959 }
912 960
913 #[test] 961 #[test]
914 fn test_validate_announcement_domain_with_trailing_slash() { 962 fn test_validate_announcement_domain_with_trailing_slash() {
915 use crate::config::ArchiveConfig; 963 use crate::config::Config;
916 use crate::nostr::policy::AnnouncementResult; 964 use crate::nostr::policy::AnnouncementResult;
917 965
918 let keys = create_test_keys(); 966 let keys = create_test_keys();
@@ -924,7 +972,11 @@ mod tests {
924 ); 972 );
925 973
926 // Should accept even when domain parameter has trailing slash 974 // Should accept even when domain parameter has trailing slash
927 let result = validate_announcement(&event, "gitnostr.com/", &ArchiveConfig::default()); 975 let config = Config {
976 domain: "gitnostr.com/".to_string(),
977 ..Config::for_testing()
978 };
979 let result = validate_announcement(&event, &config);
928 assert!(matches!(result, AnnouncementResult::Accept)); 980 assert!(matches!(result, AnnouncementResult::Accept));
929 } 981 }
930 982
@@ -964,7 +1016,7 @@ mod tests {
964 1016
965 #[test] 1017 #[test]
966 fn test_validate_announcement_archive_mode_npub() { 1018 fn test_validate_announcement_archive_mode_npub() {
967 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; 1019 use crate::config::Config;
968 use crate::nostr::policy::AnnouncementResult; 1020 use crate::nostr::policy::AnnouncementResult;
969 1021
970 let keys = create_test_keys(); 1022 let keys = create_test_keys();
@@ -978,20 +1030,21 @@ mod tests {
978 vec!["wss://other-service.com"], 1030 vec!["wss://other-service.com"],
979 ); 1031 );
980 1032
981 // Create archive config that whitelists this npub 1033 // Create config that whitelists this npub
982 let archive_config = ArchiveConfig { 1034 let config = Config {
983 archive_all: false, 1035 domain: "gitnostr.com".to_string(),
984 whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], 1036 archive_whitelist: npub,
985 read_only: false, 1037 archive_read_only: Some(false),
1038 ..Config::for_testing()
986 }; 1039 };
987 1040
988 let result = validate_announcement(&event, "gitnostr.com", &archive_config); 1041 let result = validate_announcement(&event, &config);
989 assert!(matches!(result, AnnouncementResult::AcceptArchive)); 1042 assert!(matches!(result, AnnouncementResult::AcceptArchive));
990 } 1043 }
991 1044
992 #[test] 1045 #[test]
993 fn test_validate_announcement_archive_mode_identifier() { 1046 fn test_validate_announcement_archive_mode_identifier() {
994 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; 1047 use crate::config::Config;
995 use crate::nostr::policy::AnnouncementResult; 1048 use crate::nostr::policy::AnnouncementResult;
996 1049
997 let keys = create_test_keys(); 1050 let keys = create_test_keys();
@@ -1004,20 +1057,21 @@ mod tests {
1004 vec!["wss://other-service.com"], 1057 vec!["wss://other-service.com"],
1005 ); 1058 );
1006 1059
1007 // Create archive config that whitelists this identifier 1060 // Create config that whitelists this identifier
1008 let archive_config = ArchiveConfig { 1061 let config = Config {
1009 archive_all: false, 1062 domain: "gitnostr.com".to_string(),
1010 whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], 1063 archive_whitelist: "bitcoin-core".to_string(),
1011 read_only: false, 1064 archive_read_only: Some(false),
1065 ..Config::for_testing()
1012 }; 1066 };
1013 1067
1014 let result = validate_announcement(&event, "gitnostr.com", &archive_config); 1068 let result = validate_announcement(&event, &config);
1015 assert!(matches!(result, AnnouncementResult::AcceptArchive)); 1069 assert!(matches!(result, AnnouncementResult::AcceptArchive));
1016 } 1070 }
1017 1071
1018 #[test] 1072 #[test]
1019 fn test_validate_announcement_archive_mode_repository() { 1073 fn test_validate_announcement_archive_mode_repository() {
1020 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; 1074 use crate::config::Config;
1021 use crate::nostr::policy::AnnouncementResult; 1075 use crate::nostr::policy::AnnouncementResult;
1022 1076
1023 let keys = create_test_keys(); 1077 let keys = create_test_keys();
@@ -1031,23 +1085,21 @@ mod tests {
1031 vec!["wss://other-service.com"], 1085 vec!["wss://other-service.com"],
1032 ); 1086 );
1033 1087
1034 // Create archive config that whitelists this specific repo 1088 // Create config that whitelists this specific repo
1035 let archive_config = ArchiveConfig { 1089 let config = Config {
1036 archive_all: false, 1090 domain: "gitnostr.com".to_string(),
1037 whitelist: vec![ArchiveWhitelistEntry::Repository { 1091 archive_whitelist: format!("{}/linux", npub),
1038 npub, 1092 archive_read_only: Some(false),
1039 identifier: "linux".into(), 1093 ..Config::for_testing()
1040 }],
1041 read_only: false,
1042 }; 1094 };
1043 1095
1044 let result = validate_announcement(&event, "gitnostr.com", &archive_config); 1096 let result = validate_announcement(&event, &config);
1045 assert!(matches!(result, AnnouncementResult::AcceptArchive)); 1097 assert!(matches!(result, AnnouncementResult::AcceptArchive));
1046 } 1098 }
1047 1099
1048 #[test] 1100 #[test]
1049 fn test_validate_announcement_archive_all() { 1101 fn test_validate_announcement_archive_all() {
1050 use crate::config::ArchiveConfig; 1102 use crate::config::Config;
1051 use crate::nostr::policy::AnnouncementResult; 1103 use crate::nostr::policy::AnnouncementResult;
1052 1104
1053 let keys = create_test_keys(); 1105 let keys = create_test_keys();
@@ -1060,20 +1112,21 @@ mod tests {
1060 vec!["wss://other-service.com"], 1112 vec!["wss://other-service.com"],
1061 ); 1113 );
1062 1114
1063 // Create archive config with archive_all enabled 1115 // Config with archive_all enabled
1064 let archive_config = ArchiveConfig { 1116 let config = Config {
1117 domain: "gitnostr.com".to_string(),
1065 archive_all: true, 1118 archive_all: true,
1066 whitelist: Vec::new(), 1119 archive_read_only: Some(false),
1067 read_only: false, 1120 ..Config::for_testing()
1068 }; 1121 };
1069 1122
1070 let result = validate_announcement(&event, "gitnostr.com", &archive_config); 1123 let result = validate_announcement(&event, &config);
1071 assert!(matches!(result, AnnouncementResult::AcceptArchive)); 1124 assert!(matches!(result, AnnouncementResult::AcceptArchive));
1072 } 1125 }
1073 1126
1074 #[test] 1127 #[test]
1075 fn test_validate_announcement_reject_not_in_whitelist() { 1128 fn test_validate_announcement_reject_not_in_whitelist() {
1076 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; 1129 use crate::config::Config;
1077 use crate::nostr::policy::AnnouncementResult; 1130 use crate::nostr::policy::AnnouncementResult;
1078 1131
1079 let keys = create_test_keys(); 1132 let keys = create_test_keys();
@@ -1086,20 +1139,21 @@ mod tests {
1086 vec!["wss://other-service.com"], 1139 vec!["wss://other-service.com"],
1087 ); 1140 );
1088 1141
1089 // Create archive config that whitelists different identifier 1142 // Config that whitelists different identifier
1090 let archive_config = ArchiveConfig { 1143 let config = Config {
1091 archive_all: false, 1144 domain: "gitnostr.com".to_string(),
1092 whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], 1145 archive_whitelist: "bitcoin-core".to_string(),
1093 read_only: false, 1146 archive_read_only: Some(false),
1147 ..Config::for_testing()
1094 }; 1148 };
1095 1149
1096 let result = validate_announcement(&event, "gitnostr.com", &archive_config); 1150 let result = validate_announcement(&event, &config);
1097 assert!(matches!(result, AnnouncementResult::Reject(_))); 1151 assert!(matches!(result, AnnouncementResult::Reject(_)));
1098 } 1152 }
1099 1153
1100 #[test] 1154 #[test]
1101 fn test_validate_announcement_grasp01_takes_precedence() { 1155 fn test_validate_announcement_grasp01_takes_precedence() {
1102 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; 1156 use crate::config::Config;
1103 use crate::nostr::policy::AnnouncementResult; 1157 use crate::nostr::policy::AnnouncementResult;
1104 1158
1105 let keys = create_test_keys(); 1159 let keys = create_test_keys();
@@ -1113,19 +1167,20 @@ mod tests {
1113 ); 1167 );
1114 1168
1115 // With archive_read_only=false, GRASP-01 Accept takes precedence 1169 // With archive_read_only=false, GRASP-01 Accept takes precedence
1116 let archive_config = ArchiveConfig { 1170 let config = Config {
1171 domain: "gitnostr.com".to_string(),
1117 archive_all: true, 1172 archive_all: true,
1118 whitelist: Vec::new(), 1173 archive_read_only: Some(false),
1119 read_only: false, 1174 ..Config::for_testing()
1120 }; 1175 };
1121 1176
1122 let result = validate_announcement(&event, "gitnostr.com", &archive_config); 1177 let result = validate_announcement(&event, &config);
1123 assert!(matches!(result, AnnouncementResult::Accept)); 1178 assert!(matches!(result, AnnouncementResult::Accept));
1124 } 1179 }
1125 1180
1126 #[test] 1181 #[test]
1127 fn test_archive_read_only_rejects_non_whitelisted() { 1182 fn test_archive_read_only_rejects_non_whitelisted() {
1128 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; 1183 use crate::config::Config;
1129 use crate::nostr::policy::AnnouncementResult; 1184 use crate::nostr::policy::AnnouncementResult;
1130 1185
1131 let keys = create_test_keys(); 1186 let keys = create_test_keys();
@@ -1140,19 +1195,20 @@ mod tests {
1140 1195
1141 // With archive_read_only=true and whitelist that doesn't include this repo, 1196 // With archive_read_only=true and whitelist that doesn't include this repo,
1142 // should reject even though it lists our service 1197 // should reject even though it lists our service
1143 let archive_config = ArchiveConfig { 1198 let config = Config {
1144 archive_all: false, 1199 domain: "gitnostr.com".to_string(),
1145 whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], 1200 archive_whitelist: "bitcoin-core".to_string(),
1146 read_only: true, 1201 archive_read_only: Some(true),
1202 ..Config::for_testing()
1147 }; 1203 };
1148 1204
1149 let result = validate_announcement(&event, "gitnostr.com", &archive_config); 1205 let result = validate_announcement(&event, &config);
1150 assert!(matches!(result, AnnouncementResult::Reject(_))); 1206 assert!(matches!(result, AnnouncementResult::Reject(_)));
1151 } 1207 }
1152 1208
1153 #[test] 1209 #[test]
1154 fn test_archive_read_only_accepts_whitelisted() { 1210 fn test_archive_read_only_accepts_whitelisted() {
1155 use crate::config::{ArchiveConfig, ArchiveWhitelistEntry}; 1211 use crate::config::Config;
1156 use crate::nostr::policy::AnnouncementResult; 1212 use crate::nostr::policy::AnnouncementResult;
1157 1213
1158 let keys = create_test_keys(); 1214 let keys = create_test_keys();
@@ -1168,19 +1224,20 @@ mod tests {
1168 1224
1169 // With archive_read_only=true and whitelist that DOES include this repo, 1225 // With archive_read_only=true and whitelist that DOES include this repo,
1170 // should accept as AcceptArchive 1226 // should accept as AcceptArchive
1171 let archive_config = ArchiveConfig { 1227 let config = Config {
1172 archive_all: false, 1228 domain: "gitnostr.com".to_string(),
1173 whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], 1229 archive_whitelist: npub,
1174 read_only: true, 1230 archive_read_only: Some(true),
1231 ..Config::for_testing()
1175 }; 1232 };
1176 1233
1177 let result = validate_announcement(&event, "gitnostr.com", &archive_config); 1234 let result = validate_announcement(&event, &config);
1178 assert!(matches!(result, AnnouncementResult::AcceptArchive)); 1235 assert!(matches!(result, AnnouncementResult::AcceptArchive));
1179 } 1236 }
1180 1237
1181 #[test] 1238 #[test]
1182 fn test_archive_read_only_with_archive_all() { 1239 fn test_archive_read_only_with_archive_all() {
1183 use crate::config::ArchiveConfig; 1240 use crate::config::Config;
1184 use crate::nostr::policy::AnnouncementResult; 1241 use crate::nostr::policy::AnnouncementResult;
1185 1242
1186 let keys = create_test_keys(); 1243 let keys = create_test_keys();
@@ -1195,13 +1252,67 @@ mod tests {
1195 1252
1196 // With archive_read_only=true and archive_all=true, 1253 // With archive_read_only=true and archive_all=true,
1197 // should accept as AcceptArchive 1254 // should accept as AcceptArchive
1198 let archive_config = ArchiveConfig { 1255 let config = Config {
1256 domain: "gitnostr.com".to_string(),
1199 archive_all: true, 1257 archive_all: true,
1200 whitelist: Vec::new(), 1258 archive_read_only: Some(true),
1201 read_only: true, 1259 ..Config::for_testing()
1202 }; 1260 };
1203 1261
1204 let result = validate_announcement(&event, "gitnostr.com", &archive_config); 1262 let result = validate_announcement(&event, &config);
1205 assert!(matches!(result, AnnouncementResult::AcceptArchive)); 1263 assert!(matches!(result, AnnouncementResult::AcceptArchive));
1206 } 1264 }
1265
1266 #[test]
1267 fn test_repository_whitelist_accepts_matching() {
1268 use crate::config::Config;
1269 use crate::nostr::policy::AnnouncementResult;
1270
1271 let keys = create_test_keys();
1272 let npub = keys.public_key().to_bech32().unwrap();
1273
1274 // Create announcement that lists our service
1275 let event = create_announcement_event(
1276 &keys,
1277 "test-repo",
1278 vec!["https://gitnostr.com/alice/test-repo.git"],
1279 vec!["wss://gitnostr.com"],
1280 );
1281
1282 // Config with repository whitelist that includes this repo
1283 let config = Config {
1284 domain: "gitnostr.com".to_string(),
1285 repository_whitelist: npub,
1286 ..Config::for_testing()
1287 };
1288
1289 let result = validate_announcement(&event, &config);
1290 assert!(matches!(result, AnnouncementResult::Accept));
1291 }
1292
1293 #[test]
1294 fn test_repository_whitelist_rejects_non_matching() {
1295 use crate::config::Config;
1296 use crate::nostr::policy::AnnouncementResult;
1297
1298 let keys = create_test_keys();
1299
1300 // Create announcement that lists our service
1301 let event = create_announcement_event(
1302 &keys,
1303 "test-repo",
1304 vec!["https://gitnostr.com/alice/test-repo.git"],
1305 vec!["wss://gitnostr.com"],
1306 );
1307
1308 // Config with repository whitelist that does NOT include this repo
1309 let config = Config {
1310 domain: "gitnostr.com".to_string(),
1311 repository_whitelist: "bitcoin-core".to_string(),
1312 ..Config::for_testing()
1313 };
1314
1315 let result = validate_announcement(&event, &config);
1316 assert!(matches!(result, AnnouncementResult::Reject(_)));
1317 }
1207} 1318}
diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs
index db87976..15a6e58 100644
--- a/src/nostr/policy/announcement.rs
+++ b/src/nostr/policy/announcement.rs
@@ -5,7 +5,7 @@
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::config::Config;
9use crate::nostr::events::{validate_announcement, RepositoryAnnouncement}; 9use crate::nostr::events::{validate_announcement, RepositoryAnnouncement};
10 10
11/// Result of announcement policy evaluation 11/// Result of announcement policy evaluation
@@ -25,15 +25,12 @@ pub enum AnnouncementResult {
25#[derive(Clone)] 25#[derive(Clone)]
26pub struct AnnouncementPolicy { 26pub struct AnnouncementPolicy {
27 ctx: PolicyContext, 27 ctx: PolicyContext,
28 archive_config: ArchiveConfig, 28 config: Config,
29} 29}
30 30
31impl AnnouncementPolicy { 31impl AnnouncementPolicy {
32 pub fn new(ctx: PolicyContext, archive_config: ArchiveConfig) -> Self { 32 pub fn new(ctx: PolicyContext, config: Config) -> Self {
33 Self { 33 Self { ctx, config }
34 ctx,
35 archive_config,
36 }
37 } 34 }
38 35
39 /// Validate a repository announcement event 36 /// Validate a repository announcement event
@@ -44,8 +41,7 @@ impl AnnouncementPolicy {
44 /// or `Reject` with reason. 41 /// or `Reject` with reason.
45 pub async fn validate(&self, event: &Event) -> AnnouncementResult { 42 pub async fn validate(&self, event: &Event) -> AnnouncementResult {
46 // First, try validation (GRASP-01 + GRASP-05) 43 // First, try validation (GRASP-01 + GRASP-05)
47 let validation_result = 44 let validation_result = validate_announcement(event, &self.config);
48 validate_announcement(event, &self.ctx.domain, &self.archive_config);
49 45
50 match validation_result { 46 match validation_result {
51 AnnouncementResult::Reject(reason) => { 47 AnnouncementResult::Reject(reason) => {