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.
This commit is contained in:
DanConwayDev
2026-01-12 21:21:52 +00:00
parent a12927181c
commit 82b56c37b2
9 changed files with 601 additions and 159 deletions
+26 -1
View File
@@ -202,4 +202,29 @@
# CLI: --archive-read-only
# Default: true if NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set, false otherwise
# Note: Setting to true without archive config causes startup error
# NGIT_ARCHIVE_READ_ONLY=
# Note: Cannot be used with NGIT_REPOSITORY_WHITELIST (mutually exclusive)
# NGIT_ARCHIVE_READ_ONLY=
# ============================================================================
# REPOSITORY WHITELIST
# ============================================================================
# Whitelist specific repos/pubkeys/identifiers for GRASP-01 acceptance
# Comma-separated list supporting three formats (same as archive whitelist):
# <npub> - Accept all repos from this pubkey (if they list our service)
# <npub>/<identifier> - Accept specific repo (if it lists our service)
# <identifier> - Accept repos with this identifier (if they list our service)
#
# Difference from archive whitelist:
# - Repository whitelist: Announcements MUST list our service AND match whitelist
# - Archive whitelist: Announcements don't need to list our service, just match whitelist
#
# CLI: --repository-whitelist <list>
# Default: (empty - all repos listing our service are accepted)
# Examples:
# NGIT_REPOSITORY_WHITELIST=npub1alice...
# NGIT_REPOSITORY_WHITELIST=npub1alice...,npub1bob.../linux
# NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
# Note: Cannot be used with NGIT_ARCHIVE_READ_ONLY=true (mutually exclusive)
# Note: When set, NIP-11 curation field will indicate curated repository acceptance
# NGIT_REPOSITORY_WHITELIST=
+1
View File
@@ -36,6 +36,7 @@ Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15
- **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself
- **Integrated Authorization**: Push validation happens inline during the Git receive-pack operation
- **GRASP-01 Compliant**: Core service requirements for Git hosting with Nostr authorization
- **Repository Whitelist**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode)
- **GRASP-02 Proactive Sync**: Sophisticated relay-to-relay event and git data synchronization
- **NIP-77 Negentropy**: Efficient set reconciliation with automatic fallback to REQ+EOSE
- **Live & Historic Sync**: Real-time event streaming plus catch-up for past events
+99
View File
@@ -617,6 +617,13 @@ NGIT_ARCHIVE_ALL=false
NGIT_ARCHIVE_WHITELIST=
# → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either
# NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set"
# ERROR: Cannot use repository whitelist with archive read-only
NGIT_ARCHIVE_READ_ONLY=true
NGIT_ARCHIVE_WHITELIST=npub1alice...
NGIT_REPOSITORY_WHITELIST=npub1bob...
# → Server fails to start: "NGIT_REPOSITORY_WHITELIST cannot be used with
# NGIT_ARCHIVE_READ_ONLY=true"
```
**NIP-11 Impact:**
@@ -645,6 +652,98 @@ NGIT_ARCHIVE_READ_ONLY=false
---
### Repository Whitelist
#### `NGIT_REPOSITORY_WHITELIST`
**Description:** Whitelist specific repositories/pubkeys/identifiers for GRASP-01 acceptance
**Type:** Comma-separated list
**Default:** Empty (all repos listing our service are accepted)
**Required:** No
**Format:** Same as `NGIT_ARCHIVE_WHITELIST`:
- `npub1...` - Accept all repos from this pubkey (if they list our service)
- `npub1.../identifier` - Accept specific repo (if it lists our service)
- `identifier` - Accept repos with this identifier (if they list our service)
**Difference from Archive Whitelist:**
- **Repository whitelist**: Announcements **MUST** list our service **AND** match whitelist
- **Archive whitelist**: Announcements don't need to list our service, just match whitelist
**Examples:**
```bash
# Accept only repos from specific pubkey (that list our service)
NGIT_REPOSITORY_WHITELIST=npub1alice23
# Accept specific repos only
NGIT_REPOSITORY_WHITELIST=npub1alice23/linux,npub1bob23/bitcoin-core
# Accept repos with specific identifiers
NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
# Combined whitelist
NGIT_REPOSITORY_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core
```
**Behavior:**
- When set:
- Announcements **must** list our service in both `clone` and `relays` tags (GRASP-01 requirement)
- Announcements **must** match the whitelist (pubkey, repo, or identifier)
- NIP-11 `curation` field set to: `"Accepts only whitelisted repositories and maintainers that list this service"`
- When empty (default):
- All announcements listing our service are accepted (standard GRASP-01 behavior)
**Error Conditions:**
```bash
# ERROR: Cannot use with archive read-only mode
NGIT_ARCHIVE_READ_ONLY=true
NGIT_ARCHIVE_WHITELIST=npub1archive...
NGIT_REPOSITORY_WHITELIST=npub1bob...
# → Server fails to start: "NGIT_REPOSITORY_WHITELIST cannot be used with
# NGIT_ARCHIVE_READ_ONLY=true. Either set NGIT_ARCHIVE_READ_ONLY=false
# or use NGIT_ARCHIVE_WHITELIST instead"
```
**NIP-11 Impact:**
When `NGIT_REPOSITORY_WHITELIST` is set:
- `curation`: `"Accepts only whitelisted repositories and maintainers that list this service"`
- `supported_grasps`: Does **not** include `GRASP-05` (still GRASP-01 compliant)
**Use Cases:**
```bash
# Curated relay for specific projects (GRASP-01 mode)
NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust
# Personal relay for self and trusted collaborators
NGIT_REPOSITORY_WHITELIST=npub1me...,npub1alice...,npub1bob...
# Project-specific relay (e.g., Rust ecosystem)
NGIT_REPOSITORY_WHITELIST=rust,cargo,rustc,tokio,serde
# Hybrid: specific projects AND specific maintainer's repos
NGIT_REPOSITORY_WHITELIST=bitcoin-core,npub1alice...
```
**Comparison Table:**
| Configuration | Lists Service? | Matches Whitelist? | Result |
|---------------|----------------|-------------------|---------|
| No whitelist | Yes | N/A | ✅ Accept (GRASP-01) |
| No whitelist | No | N/A | ❌ Reject |
| Repository whitelist | Yes | Yes | ✅ Accept (GRASP-01) |
| Repository whitelist | Yes | No | ❌ Reject (not whitelisted) |
| Repository whitelist | No | Yes | ❌ Reject (doesn't list service) |
| Archive whitelist (read-only=true) | No | Yes | ✅ Accept (GRASP-05) |
| Archive whitelist (read-only=false) | Yes | N/A | ✅ Accept (GRASP-01) |
| Archive whitelist (read-only=false) | No | Yes | ✅ Accept (GRASP-05) |
---
### Logging Configuration
#### `RUST_LOG`
+15
View File
@@ -207,6 +207,20 @@ let
- Repository announcements not listing this service are accepted per whitelist/archive-all
Default: true if archiveAll or archiveWhitelist is set, false otherwise
Note: Setting to true without archive config causes startup error
Note: Cannot be used with repositoryWhitelist (mutually exclusive)
'';
};
repositoryWhitelist = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "npub1alice..." "npub1bob.../linux" "bitcoin-core" ];
description = ''
Repository whitelist for GRASP-01 acceptance.
Announcements must BOTH list our service AND match this whitelist.
Formats: <npub>, <npub>/<identifier>, <identifier>
Cannot be used with archiveReadOnly=true (mutually exclusive)
When set, NIP-11 curation field indicates curated repository acceptance
'';
};
@@ -252,6 +266,7 @@ let
toString cfg.naughtyListExpirationHours;
NGIT_ARCHIVE_ALL = toString cfg.archiveAll;
NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist;
NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist;
RUST_LOG = cfg.logLevel;
} // optionalAttrs (cfg.relayName != null) {
NGIT_RELAY_NAME = cfg.relayName;
+171 -41
View File
@@ -5,21 +5,21 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// GRASP-05 Archive whitelist entry
/// Whitelist entry for repository/archive filtering
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ArchiveWhitelistEntry {
/// Archive all repos from this pubkey: "npub1..."
pub enum WhitelistEntry {
/// All repos from this pubkey: "npub1..."
Pubkey(String),
/// Archive specific repo: "npub1.../identifier"
/// Specific repo: "npub1.../identifier"
Repository { npub: String, identifier: String },
/// Archive any repo with this identifier: "identifier"
/// Any repo with this identifier: "identifier"
Identifier(String),
}
impl ArchiveWhitelistEntry {
impl WhitelistEntry {
/// Parse a whitelist entry from string
///
/// Formats:
@@ -83,6 +83,20 @@ impl ArchiveWhitelistEntry {
Self::Identifier(i) => identifier == i,
}
}
/// Parse whitelist from comma-separated string
pub fn parse_whitelist(input: &str) -> Result<Vec<Self>> {
if input.trim().is_empty() {
return Ok(Vec::new());
}
input
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(Self::parse)
.collect()
}
}
/// GRASP-05 Archive mode configuration
@@ -97,7 +111,7 @@ pub struct ArchiveConfig {
/// Whitelist entries for selective archiving
///
/// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
pub whitelist: Vec<ArchiveWhitelistEntry>,
pub whitelist: Vec<WhitelistEntry>,
/// Read-only archive mode: relay is a read-only sync of archived repositories
///
@@ -127,20 +141,6 @@ impl ArchiveConfig {
.iter()
.any(|entry| entry.matches(npub, identifier))
}
/// Parse archive whitelist from comma-separated string
pub fn parse_whitelist(input: &str) -> Result<Vec<ArchiveWhitelistEntry>> {
if input.trim().is_empty() {
return Ok(Vec::new());
}
input
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(ArchiveWhitelistEntry::parse)
.collect()
}
}
impl Default for ArchiveConfig {
@@ -153,6 +153,39 @@ impl Default for ArchiveConfig {
}
}
/// Repository whitelist configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepositoryConfig {
/// Whitelist entries for selective repository acceptance
///
/// If empty, all repositories listing the service are accepted (GRASP-01 mode).
pub whitelist: Vec<WhitelistEntry>,
}
impl RepositoryConfig {
/// Check if repository whitelist is enabled (non-empty whitelist)
pub fn enabled(&self) -> bool {
!self.whitelist.is_empty()
}
/// Check if an announcement matches the repository whitelist
///
/// Returns true if announcement matches any whitelist entry
pub fn matches(&self, npub: &str, identifier: &str) -> bool {
self.whitelist
.iter()
.any(|entry| entry.matches(npub, identifier))
}
}
impl Default for RepositoryConfig {
fn default() -> Self {
Self {
whitelist: Vec::new(),
}
}
}
/// Database backend type for the relay
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)]
#[serde(rename_all = "lowercase")]
@@ -325,6 +358,12 @@ pub struct Config {
/// Throws error if set to true without archive_all or archive_whitelist
#[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")]
pub archive_read_only: Option<bool>,
/// Repository whitelist: comma-separated list of npub/identifier/npub/identifier entries
/// Formats: "npub1...", "npub1.../identifier", "identifier"
/// When set, only announcements matching the whitelist AND listing the service are accepted
#[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")]
pub repository_whitelist: String,
}
impl Config {
@@ -430,7 +469,7 @@ impl Config {
/// Read-only mode defaults to true if archive mode is enabled, false otherwise.
/// Throws error if explicitly set to true without archive mode enabled.
pub fn archive_config(&self) -> Result<ArchiveConfig> {
let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?;
let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist)?;
let archive_enabled = self.archive_all || !whitelist.is_empty();
let read_only = match self.archive_read_only {
@@ -456,6 +495,28 @@ impl Config {
})
}
/// Get parsed repository whitelist configuration
///
/// Throws error if repository_whitelist is set together with archive_read_only=true
pub fn repository_config(&self) -> Result<RepositoryConfig> {
let whitelist = WhitelistEntry::parse_whitelist(&self.repository_whitelist)?;
// Validate incompatible configurations
if !whitelist.is_empty() {
let archive_config = self.archive_config()?;
if archive_config.read_only {
return Err(anyhow!(
"NGIT_REPOSITORY_WHITELIST cannot be used with NGIT_ARCHIVE_READ_ONLY=true. \
Archive read-only mode rejects announcements that don't match the archive whitelist, \
regardless of service listing. Either set NGIT_ARCHIVE_READ_ONLY=false or use \
NGIT_ARCHIVE_WHITELIST instead of NGIT_REPOSITORY_WHITELIST."
));
}
}
Ok(RepositoryConfig { whitelist })
}
/// Create config for testing
#[cfg(test)]
pub fn for_testing() -> Self {
@@ -489,6 +550,7 @@ impl Config {
archive_all: false,
archive_whitelist: String::new(),
archive_read_only: None,
repository_whitelist: String::new(),
}
}
}
@@ -629,9 +691,9 @@ mod tests {
// Generate a valid test npub
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let entry = ArchiveWhitelistEntry::parse(&test_npub).unwrap();
assert!(matches!(entry, ArchiveWhitelistEntry::Pubkey(_)));
if let ArchiveWhitelistEntry::Pubkey(npub) = entry {
let entry = WhitelistEntry::parse(&test_npub).unwrap();
assert!(matches!(entry, WhitelistEntry::Pubkey(_)));
if let WhitelistEntry::Pubkey(npub) = entry {
assert_eq!(npub, test_npub);
}
}
@@ -640,9 +702,9 @@ mod tests {
fn test_parse_whitelist_entry_repository() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let entry = ArchiveWhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap();
assert!(matches!(entry, ArchiveWhitelistEntry::Repository { .. }));
if let ArchiveWhitelistEntry::Repository { npub, identifier } = entry {
let entry = WhitelistEntry::parse(&format!("{}/linux", test_npub)).unwrap();
assert!(matches!(entry, WhitelistEntry::Repository { .. }));
if let WhitelistEntry::Repository { npub, identifier } = entry {
assert_eq!(npub, test_npub);
assert_eq!(identifier, "linux");
}
@@ -650,16 +712,16 @@ mod tests {
#[test]
fn test_parse_whitelist_entry_identifier() {
let entry = ArchiveWhitelistEntry::parse("bitcoin-core").unwrap();
assert!(matches!(entry, ArchiveWhitelistEntry::Identifier(_)));
if let ArchiveWhitelistEntry::Identifier(id) = entry {
let entry = WhitelistEntry::parse("bitcoin-core").unwrap();
assert!(matches!(entry, WhitelistEntry::Identifier(_)));
if let WhitelistEntry::Identifier(id) = entry {
assert_eq!(id, "bitcoin-core");
}
}
#[test]
fn test_parse_whitelist_entry_invalid_npub() {
let result = ArchiveWhitelistEntry::parse("npub1invalid");
let result = WhitelistEntry::parse("npub1invalid");
assert!(result.is_err());
}
@@ -667,7 +729,7 @@ mod tests {
fn test_whitelist_entry_matches() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let entry = ArchiveWhitelistEntry::Pubkey(test_npub.clone());
let entry = WhitelistEntry::Pubkey(test_npub.clone());
assert!(entry.matches(&test_npub, "any-identifier"));
assert!(!entry.matches("npub1different", "any-identifier"));
}
@@ -676,7 +738,7 @@ mod tests {
fn test_whitelist_entry_matches_repository() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let entry = ArchiveWhitelistEntry::Repository {
let entry = WhitelistEntry::Repository {
npub: test_npub.clone(),
identifier: "linux".to_string(),
};
@@ -687,7 +749,7 @@ mod tests {
#[test]
fn test_whitelist_entry_matches_identifier() {
let entry = ArchiveWhitelistEntry::Identifier("bitcoin-core".to_string());
let entry = WhitelistEntry::Identifier("bitcoin-core".to_string());
assert!(entry.matches("npub1alice", "bitcoin-core"));
assert!(entry.matches("npub1bob", "bitcoin-core"));
assert!(!entry.matches("npub1alice", "other-repo"));
@@ -707,7 +769,7 @@ mod tests {
let config = ArchiveConfig {
archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())],
whitelist: vec![WhitelistEntry::Identifier("test".into())],
read_only: true,
};
assert!(config.enabled());
@@ -720,8 +782,8 @@ mod tests {
let config = ArchiveConfig {
archive_all: false,
whitelist: vec![
ArchiveWhitelistEntry::Pubkey(test_npub.clone()),
ArchiveWhitelistEntry::Identifier("bitcoin-core".into()),
WhitelistEntry::Pubkey(test_npub.clone()),
WhitelistEntry::Identifier("bitcoin-core".into()),
],
read_only: false,
};
@@ -745,10 +807,10 @@ mod tests {
#[test]
fn test_parse_whitelist_empty() {
let whitelist = ArchiveConfig::parse_whitelist("").unwrap();
let whitelist = WhitelistEntry::parse_whitelist("").unwrap();
assert!(whitelist.is_empty());
let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap();
let whitelist = WhitelistEntry::parse_whitelist(" ").unwrap();
assert!(whitelist.is_empty());
}
@@ -758,7 +820,7 @@ mod tests {
let keys2 = Keys::generate();
let test_npub1 = keys1.public_key().to_bech32().unwrap();
let test_npub2 = keys2.public_key().to_bech32().unwrap();
let whitelist = ArchiveConfig::parse_whitelist(&format!(
let whitelist = WhitelistEntry::parse_whitelist(&format!(
"{},bitcoin-core,{}/linux",
test_npub1, test_npub2
))
@@ -850,4 +912,72 @@ mod tests {
.to_string()
.contains("requires either"));
}
#[test]
fn test_repository_whitelist_parsing() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = Config {
repository_whitelist: format!("{},bitcoin-core", test_npub),
..Config::for_testing()
};
let repo_config = config.repository_config().unwrap();
assert_eq!(repo_config.whitelist.len(), 2);
assert!(repo_config.enabled());
}
#[test]
fn test_repository_whitelist_empty() {
let config = Config::for_testing();
let repo_config = config.repository_config().unwrap();
assert!(repo_config.whitelist.is_empty());
assert!(!repo_config.enabled());
}
#[test]
fn test_repository_whitelist_incompatible_with_archive_read_only() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = Config {
archive_all: true,
archive_read_only: Some(true),
repository_whitelist: test_npub,
..Config::for_testing()
};
let result = config.repository_config();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("cannot be used with"));
assert!(err.contains("NGIT_ARCHIVE_READ_ONLY=true"));
}
#[test]
fn test_repository_whitelist_compatible_with_archive_read_only_false() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = Config {
archive_all: true,
archive_read_only: Some(false),
repository_whitelist: test_npub,
..Config::for_testing()
};
// Should not error
assert!(config.repository_config().is_ok());
}
#[test]
fn test_repository_config_matches() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = RepositoryConfig {
whitelist: vec![
WhitelistEntry::Pubkey(test_npub.clone()),
WhitelistEntry::Identifier("bitcoin-core".into()),
],
};
assert!(config.matches(&test_npub, "any-repo"));
assert!(config.matches("npub1bob", "bitcoin-core"));
assert!(!config.matches("npub1bob", "other-repo"));
}
}
+64 -1
View File
@@ -74,8 +74,15 @@ impl RelayInformationDocument {
}
supported_grasps.push("GRASP-02".to_string());
// Build curation field for archive read-only mode
// Build curation field for archive read-only mode or repository whitelist
let repository_config = config.repository_config().ok();
let repository_whitelist_enabled = repository_config
.as_ref()
.map(|rc| rc.enabled())
.unwrap_or(false);
let curation = if archive_read_only {
// Archive read-only mode (GRASP-05 only)
if let Some(ref ac) = archive_config {
if ac.archive_all {
Some("Read-only sync of all repositories found on network".to_string())
@@ -87,6 +94,18 @@ impl RelayInformationDocument {
} else {
None
}
} else if archive_enabled && repository_whitelist_enabled {
// Both archive (non-read-only) AND repository whitelist enabled
Some(
"Accepts whitelisted repositories (with or without service listing) and whitelisted repositories that list this service"
.to_string(),
)
} else if repository_whitelist_enabled {
// Repository whitelist only
Some(
"Accepts only whitelisted repositories and maintainers that list this service"
.to_string(),
)
} else {
None
};
@@ -230,4 +249,48 @@ mod tests {
.unwrap()
.contains("Read-only sync of whitelisted"));
}
#[test]
fn test_nip11_with_repository_whitelist() {
let keys = nostr_sdk::Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let mut config = Config::for_testing();
config.domain = "relay.example.com".to_string();
config.repository_whitelist = format!("{},bitcoin-core", test_npub);
let doc = RelayInformationDocument::from_config(&config);
// Repository whitelist doesn't enable GRASP-05
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
// Should have curation field for repository whitelist
assert!(doc.curation.is_some());
assert!(doc
.curation
.unwrap()
.contains("Accepts only whitelisted repositories"));
}
#[test]
fn test_nip11_with_archive_and_repository_whitelist() {
let keys = nostr_sdk::Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let mut config = Config::for_testing();
config.domain = "relay.example.com".to_string();
config.archive_whitelist = "bitcoin-core".to_string();
config.archive_read_only = Some(false); // Non-read-only archive mode
config.repository_whitelist = test_npub;
let doc = RelayInformationDocument::from_config(&config);
// Should have GRASP-05 enabled due to archive whitelist
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
);
// Should have curation field reflecting BOTH archive and repository whitelist
assert!(doc.curation.is_some());
let curation = doc.curation.unwrap();
assert!(curation.contains("whitelisted repositories"));
assert!(curation.contains("with or without service listing"));
}
}
+24 -22
View File
@@ -51,15 +51,14 @@ impl std::fmt::Debug for Nip34WritePolicy {
impl Nip34WritePolicy {
pub fn new(
domain: impl Into<String>,
database: SharedDatabase,
git_data_path: impl Into<std::path::PathBuf>,
purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
archive_config: crate::config::ArchiveConfig,
config: crate::config::Config,
) -> Self {
let ctx = PolicyContext::new(domain, database, git_data_path, purgatory);
let ctx = PolicyContext::new(&config.domain, database, git_data_path, purgatory);
Self {
announcement_policy: AnnouncementPolicy::new(ctx.clone(), archive_config),
announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()),
state_policy: StatePolicy::new(ctx.clone()),
pr_event_policy: PrEventPolicy::new(ctx.clone()),
related_event_policy: RelatedEventPolicy::new(ctx.clone()),
@@ -568,28 +567,31 @@ pub async fn create_relay(
// Clone Arc for the write policy so both relay and policy can access the database
let git_data_path = config.effective_git_data_path();
// Parse archive configuration
let archive_config = config
.archive_config()
.map_err(|e| anyhow::anyhow!("Failed to parse archive configuration: {}", e))?;
// Parse and log archive configuration
if let Ok(archive_config) = config.archive_config() {
if archive_config.enabled() {
tracing::info!(
"GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}",
archive_config.archive_all,
archive_config.whitelist.len(),
archive_config.read_only
);
}
}
if archive_config.enabled() {
tracing::info!(
"GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}",
archive_config.archive_all,
archive_config.whitelist.len(),
archive_config.read_only
);
// Parse and log repository configuration
if let Ok(repository_config) = config.repository_config() {
if repository_config.enabled() {
tracing::info!(
"Repository whitelist enabled: whitelist_entries={}",
repository_config.whitelist.len()
);
}
}
// Create write policy with purgatory integration
let write_policy = Nip34WritePolicy::new(
&config.domain,
database.clone(),
&git_data_path,
purgatory,
archive_config,
);
let write_policy =
Nip34WritePolicy::new(database.clone(), &git_data_path, purgatory, config.clone());
let relay = LocalRelayBuilder::default()
.database(database.clone())
+196 -85
View File
@@ -362,7 +362,7 @@ impl RepositoryState {
/// Validate a repository announcement according to GRASP-01 and GRASP-05
///
/// Returns:
/// - Accept: Announcement lists our service (GRASP-01) - unless archive_read_only mode
/// - Accept: Announcement lists our service (GRASP-01) AND matches repository whitelist (if enabled)
/// - AcceptArchive: Announcement matches archive config (GRASP-05)
/// - Reject: Validation failed
///
@@ -370,11 +370,13 @@ impl RepositoryState {
/// - ONLY accept announcements matching archive whitelist/all
/// - REJECT announcements listing our service but not in whitelist (read-only sync mode)
///
/// When repository_whitelist is set:
/// - Announcements must BOTH list our service AND match the repository whitelist
///
/// Note: AcceptMaintainer is NOT returned here (requires database access)
pub fn validate_announcement(
event: &Event,
domain: &str,
archive_config: &crate::config::ArchiveConfig,
config: &crate::config::Config,
) -> crate::nostr::policy::AnnouncementResult {
use crate::nostr::policy::AnnouncementResult;
@@ -398,12 +400,33 @@ pub fn validate_announcement(
Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)),
};
// GRASP-01: Normal mode - accept if announcement lists our service
if announcement.lists_service(domain) && !archive_config.read_only {
return AnnouncementResult::Accept;
}
// Get archive and repository configs (fail-secure: reject on config errors)
let archive_config = match config.archive_config() {
Ok(c) => c,
Err(e) => return AnnouncementResult::Reject(format!("Config error: {}", e)),
};
let repository_config = match config.repository_config() {
Ok(c) => c,
Err(e) => return AnnouncementResult::Reject(format!("Config error: {}", e)),
};
let npub = announcement.owner_npub();
let lists_service = announcement.lists_service(&config.domain);
// GRASP-01: Normal mode - accept if announcement lists our service AND matches repository whitelist (if enabled)
if lists_service && !archive_config.read_only {
// Check repository whitelist if enabled
if repository_config.enabled() {
if !repository_config.matches(&npub, &announcement.identifier) {
return AnnouncementResult::Reject(format!(
"Announcement lists service but does not match repository whitelist. \
Repository {}/{} not in whitelist",
npub, announcement.identifier
));
}
}
return AnnouncementResult::Accept;
}
// GRASP-05: Archive mode - accept if announcement matches whitelist
if archive_config.matches(&npub, &announcement.identifier) {
@@ -561,7 +584,7 @@ mod tests {
#[test]
fn test_validate_announcement_success() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -572,13 +595,17 @@ mod tests {
vec!["wss://gitnostr.com"],
);
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
let config = Config {
domain: "gitnostr.com".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_validate_announcement_missing_clone() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -589,7 +616,11 @@ mod tests {
vec!["wss://gitnostr.com"],
);
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
let config = Config {
domain: "gitnostr.com".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
if let AnnouncementResult::Reject(reason) = result {
assert!(reason.contains("clone"));
} else {
@@ -599,7 +630,7 @@ mod tests {
#[test]
fn test_validate_announcement_missing_relay() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -610,7 +641,11 @@ mod tests {
vec![], // No relays
);
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
let config = Config {
domain: "gitnostr.com".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
if let AnnouncementResult::Reject(reason) = result {
assert!(reason.contains("relays"));
} else {
@@ -620,7 +655,7 @@ mod tests {
#[test]
fn test_validate_announcement_wrong_domain() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -631,7 +666,11 @@ mod tests {
vec!["wss://other-service.com"],
);
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
let config = Config {
domain: "gitnostr.com".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
@@ -855,7 +894,7 @@ mod tests {
#[test]
fn test_validate_announcement_with_trailing_slash_in_relay() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -867,14 +906,17 @@ mod tests {
);
// Should accept despite trailing slash mismatch
let result =
validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
let config = Config {
domain: "git.shakespeare.diy".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_validate_announcement_with_trailing_slash_in_clone_url() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -886,14 +928,17 @@ mod tests {
);
// Should accept despite trailing slash mismatch
let result =
validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
let config = Config {
domain: "git.shakespeare.diy".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_validate_announcement_with_trailing_slash_in_both() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -905,14 +950,17 @@ mod tests {
);
// Should accept with trailing slashes in both
let result =
validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
let config = Config {
domain: "git.shakespeare.diy".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_validate_announcement_domain_with_trailing_slash() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -924,7 +972,11 @@ mod tests {
);
// Should accept even when domain parameter has trailing slash
let result = validate_announcement(&event, "gitnostr.com/", &ArchiveConfig::default());
let config = Config {
domain: "gitnostr.com/".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Accept));
}
@@ -964,7 +1016,7 @@ mod tests {
#[test]
fn test_validate_announcement_archive_mode_npub() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -978,20 +1030,21 @@ mod tests {
vec!["wss://other-service.com"],
);
// Create archive config that whitelists this npub
let archive_config = ArchiveConfig {
archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)],
read_only: false,
// Create config that whitelists this npub
let config = Config {
domain: "gitnostr.com".to_string(),
archive_whitelist: npub,
archive_read_only: Some(false),
..Config::for_testing()
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_validate_announcement_archive_mode_identifier() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -1004,20 +1057,21 @@ mod tests {
vec!["wss://other-service.com"],
);
// Create archive config that whitelists this identifier
let archive_config = ArchiveConfig {
archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
read_only: false,
// Create config that whitelists this identifier
let config = Config {
domain: "gitnostr.com".to_string(),
archive_whitelist: "bitcoin-core".to_string(),
archive_read_only: Some(false),
..Config::for_testing()
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_validate_announcement_archive_mode_repository() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -1031,23 +1085,21 @@ mod tests {
vec!["wss://other-service.com"],
);
// Create archive config that whitelists this specific repo
let archive_config = ArchiveConfig {
archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Repository {
npub,
identifier: "linux".into(),
}],
read_only: false,
// Create config that whitelists this specific repo
let config = Config {
domain: "gitnostr.com".to_string(),
archive_whitelist: format!("{}/linux", npub),
archive_read_only: Some(false),
..Config::for_testing()
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_validate_announcement_archive_all() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -1060,20 +1112,21 @@ mod tests {
vec!["wss://other-service.com"],
);
// Create archive config with archive_all enabled
let archive_config = ArchiveConfig {
// Config with archive_all enabled
let config = Config {
domain: "gitnostr.com".to_string(),
archive_all: true,
whitelist: Vec::new(),
read_only: false,
archive_read_only: Some(false),
..Config::for_testing()
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_validate_announcement_reject_not_in_whitelist() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -1086,20 +1139,21 @@ mod tests {
vec!["wss://other-service.com"],
);
// Create archive config that whitelists different identifier
let archive_config = ArchiveConfig {
archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
read_only: false,
// Config that whitelists different identifier
let config = Config {
domain: "gitnostr.com".to_string(),
archive_whitelist: "bitcoin-core".to_string(),
archive_read_only: Some(false),
..Config::for_testing()
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
#[test]
fn test_validate_announcement_grasp01_takes_precedence() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -1113,19 +1167,20 @@ mod tests {
);
// With archive_read_only=false, GRASP-01 Accept takes precedence
let archive_config = ArchiveConfig {
let config = Config {
domain: "gitnostr.com".to_string(),
archive_all: true,
whitelist: Vec::new(),
read_only: false,
archive_read_only: Some(false),
..Config::for_testing()
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_archive_read_only_rejects_non_whitelisted() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -1140,19 +1195,20 @@ mod tests {
// With archive_read_only=true and whitelist that doesn't include this repo,
// should reject even though it lists our service
let archive_config = ArchiveConfig {
archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
read_only: true,
let config = Config {
domain: "gitnostr.com".to_string(),
archive_whitelist: "bitcoin-core".to_string(),
archive_read_only: Some(true),
..Config::for_testing()
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
#[test]
fn test_archive_read_only_accepts_whitelisted() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -1168,19 +1224,20 @@ mod tests {
// With archive_read_only=true and whitelist that DOES include this repo,
// should accept as AcceptArchive
let archive_config = ArchiveConfig {
archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)],
read_only: true,
let config = Config {
domain: "gitnostr.com".to_string(),
archive_whitelist: npub,
archive_read_only: Some(true),
..Config::for_testing()
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_archive_read_only_with_archive_all() {
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
@@ -1195,13 +1252,67 @@ mod tests {
// With archive_read_only=true and archive_all=true,
// should accept as AcceptArchive
let archive_config = ArchiveConfig {
let config = Config {
domain: "gitnostr.com".to_string(),
archive_all: true,
whitelist: Vec::new(),
read_only: true,
archive_read_only: Some(true),
..Config::for_testing()
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_repository_whitelist_accepts_matching() {
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let npub = keys.public_key().to_bech32().unwrap();
// Create announcement that lists our service
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://gitnostr.com/alice/test-repo.git"],
vec!["wss://gitnostr.com"],
);
// Config with repository whitelist that includes this repo
let config = Config {
domain: "gitnostr.com".to_string(),
repository_whitelist: npub,
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_repository_whitelist_rejects_non_matching() {
use crate::config::Config;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
// Create announcement that lists our service
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://gitnostr.com/alice/test-repo.git"],
vec!["wss://gitnostr.com"],
);
// Config with repository whitelist that does NOT include this repo
let config = Config {
domain: "gitnostr.com".to_string(),
repository_whitelist: "bitcoin-core".to_string(),
..Config::for_testing()
};
let result = validate_announcement(&event, &config);
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
}
+5 -9
View File
@@ -5,7 +5,7 @@
use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
use super::PolicyContext;
use crate::config::ArchiveConfig;
use crate::config::Config;
use crate::nostr::events::{validate_announcement, RepositoryAnnouncement};
/// Result of announcement policy evaluation
@@ -25,15 +25,12 @@ pub enum AnnouncementResult {
#[derive(Clone)]
pub struct AnnouncementPolicy {
ctx: PolicyContext,
archive_config: ArchiveConfig,
config: Config,
}
impl AnnouncementPolicy {
pub fn new(ctx: PolicyContext, archive_config: ArchiveConfig) -> Self {
Self {
ctx,
archive_config,
}
pub fn new(ctx: PolicyContext, config: Config) -> Self {
Self { ctx, config }
}
/// Validate a repository announcement event
@@ -44,8 +41,7 @@ impl AnnouncementPolicy {
/// or `Reject` with reason.
pub async fn validate(&self, event: &Event) -> AnnouncementResult {
// First, try validation (GRASP-01 + GRASP-05)
let validation_result =
validate_announcement(event, &self.ctx.domain, &self.archive_config);
let validation_result = validate_announcement(event, &self.config);
match validation_result {
AnnouncementResult::Reject(reason) => {