feat(grasp-05): add read-only mode with auto-enable for archive configs

Implements NGIT_ARCHIVE_READ_ONLY configuration option that defaults to true
when archive mode is enabled, allowing relays to operate as read-only syncs
of archived repositories.

Key changes:
- Add NGIT_ARCHIVE_READ_ONLY config option (defaults to true if archive enabled)
- NIP-11 advertises GRASP-05 support and includes curation field when read-only
- Validation logic rejects non-whitelisted repos in read-only mode
- Comprehensive tests for read-only behavior and defaults
- Full documentation in config reference, .env.example, and NixOS module

Read-only mode enables passive mirroring without being listed in announcements,
useful for backup/archive operations while preventing accidental write acceptance.
This commit is contained in:
DanConwayDev
2026-01-12 20:30:13 +00:00
parent c29191b1e1
commit a12927181c
9 changed files with 429 additions and 24 deletions
+13
View File
@@ -190,3 +190,16 @@
# NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob.../linux # NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob.../linux
# NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust # NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust
# NGIT_ARCHIVE_WHITELIST= # NGIT_ARCHIVE_WHITELIST=
# Archive read-only mode (relay is read-only sync of archived repositories)
# When true:
# - NIP-11 includes GRASP-05 in supported_grasps
# - NIP-11 curation field describes archive scope
# - Repository announcements not listing this service are accepted per whitelist/archive-all
# When false:
# - Archive mode disabled (standard GRASP-01 operation)
#
# 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=
+2 -2
View File
@@ -141,11 +141,11 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f
- ✅ Accept repositories not listing this instance via configurable whitelist - ✅ Accept repositories not listing this instance via configurable whitelist
- ✅ Three whitelist formats: `<npub>`, `<npub>/<identifier>`, `<identifier>` - ✅ Three whitelist formats: `<npub>`, `<npub>/<identifier>`, `<identifier>`
- ✅ Read-only mirroring with full GRASP-02 sync (git data + Nostr events) - ✅ Read-only mirroring with full GRASP-02 sync (git data + Nostr events) - **default behavior**
- ✅ Archive-all mode for complete ecosystem mirrors - ✅ Archive-all mode for complete ecosystem mirrors
- ✅ Fail-fast npub validation at startup - ✅ Fail-fast npub validation at startup
**Archive mode enables backup/mirror operation** - accept repository announcements that don't list your relay, useful for creating archives of critical projects or running comprehensive mirrors. Archived repositories are read-only with full event and git data sync. **Archive mode enables backup/mirror operation** - accept repository announcements that don't list your relay, useful for creating archives of critical projects or running comprehensive mirrors. Archived repositories are read-only by default (`NGIT_ARCHIVE_READ_ONLY=true`) with full event and git data sync.
**See**: [GRASP-05 Archive Mode](docs/explanation/grasp-05-archive.md) **See**: [GRASP-05 Archive Mode](docs/explanation/grasp-05-archive.md)
+20 -5
View File
@@ -35,14 +35,17 @@ Archive mode relaxes the "must list service" requirement for whitelisted reposit
**Configuration:** **Configuration:**
```bash ```bash
# Specific repos (safest) # Specific repos (safest) - read-only by default
NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin
# NGIT_ARCHIVE_READ_ONLY defaults to true
# All repos from trusted maintainers # All repos from trusted maintainers
NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob... NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob...
# NGIT_ARCHIVE_READ_ONLY defaults to true
# Archive everything (⚠️ storage risk) # Archive everything (⚠️ storage risk)
NGIT_ARCHIVE_ALL=true NGIT_ARCHIVE_ALL=true
# NGIT_ARCHIVE_READ_ONLY defaults to true
``` ```
### Validation Priority ### Validation Priority
@@ -63,11 +66,21 @@ Archived repos use the same directory structure as hosted repos:
<git_data_path>/ <git_data_path>/
npub1alice.../ npub1alice.../
hosted-repo.git/ # Lists your service (writable) hosted-repo.git/ # Lists your service (writable)
archived-repo.git/ # Whitelisted (read-only) archived-repo.git/ # Whitelisted (read-only by default)
``` ```
**No flags or metadata** - archive status determined dynamically from config + announcement contents. **No flags or metadata** - archive status determined dynamically from config + announcement contents.
### Read-Only Mode
By default, archive mode operates in read-only mode (`NGIT_ARCHIVE_READ_ONLY=true`):
- Repository announcements are accepted per whitelist/archive-all configuration
- The service is **not listed** in accepted announcements (passive sync only)
- NIP-11 document advertises `GRASP-05` support
- NIP-11 `curation` field indicates read-only sync scope:
- `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`)
- `"Read-only sync of whitelisted repositories and maintainers"` (if whitelist configured)
### Full Sync ### Full Sync
Archived repositories trigger complete GRASP-02 sync: Archived repositories trigger complete GRASP-02 sync:
@@ -129,12 +142,14 @@ Watch for:
## Comparison: Hosted vs Archived ## Comparison: Hosted vs Archived
| Aspect | Hosted (GRASP-01) | Archived (GRASP-05) | | Aspect | Hosted (GRASP-01) | Archived (GRASP-05 Read-Only) |
|--------|-------------------|---------------------| |--------|-------------------|-------------------------------|
| Announcement must list you | ✅ Required | ❌ Whitelisted instead | | Announcement must list you | ✅ Required | ❌ Whitelisted instead |
| Git pushes | ✅ Accepted | ❌ Rejected (read-only) | | Git pushes | ✅ Accepted | ❌ Rejected (read-only) |
| GRASP-02 sync | ✅ Full sync | ✅ Full sync | | GRASP-02 sync | ✅ Full sync | ✅ Full sync |
| Relay discovery | ✅ Listed | ❌ Not listed | | Relay discovery | ✅ Listed in announcements | ❌ Not listed (passive sync) |
| NIP-11 supported_grasps | `["GRASP-01", "GRASP-02"]` | `["GRASP-01", "GRASP-05", "GRASP-02"]` |
| NIP-11 curation field | `null` | Describes archive scope |
| Use case | Hosting workspace | Backup/mirror | | Use case | Hosting workspace | Backup/mirror |
## Related Documentation ## Related Documentation
+71
View File
@@ -574,6 +574,77 @@ NGIT_ARCHIVE_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core
--- ---
#### `NGIT_ARCHIVE_READ_ONLY`
**Description:** Configure relay as read-only sync of archived repositories
**Type:** Boolean
**Default:** `true` if `NGIT_ARCHIVE_ALL` or `NGIT_ARCHIVE_WHITELIST` is set, `false` otherwise
**Required:** No
**Examples:**
```bash
# Explicitly enable (requires archive mode)
NGIT_ARCHIVE_READ_ONLY=true
# Explicitly disable (writable archive repos)
NGIT_ARCHIVE_READ_ONLY=false
# Automatic (default behavior)
# - If NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set → true
# - Otherwise → false
# NGIT_ARCHIVE_READ_ONLY=
```
**Behavior:**
- When `true`:
- NIP-11 document includes `GRASP-05` in `supported_grasps`
- NIP-11 `curation` field describes the archive scope
- Repository announcements not listing this service are accepted per whitelist/archive-all
- When `false`:
- Archive mode disabled (standard GRASP-01 operation)
- When unset (default):
- Automatically `true` if archive mode configured
- Automatically `false` otherwise
**Error Conditions:**
```bash
# ERROR: Cannot set read-only without archive config
NGIT_ARCHIVE_READ_ONLY=true
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"
```
**NIP-11 Impact:**
When `NGIT_ARCHIVE_READ_ONLY=true`:
- `supported_grasps`: includes `"GRASP-05"`
- `curation`: Set to one of:
- `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`)
- `"Read-only sync of whitelisted repositories and maintainers"` (if `NGIT_ARCHIVE_WHITELIST` set)
**Use Cases:**
```bash
# Public archive of entire ecosystem
NGIT_ARCHIVE_ALL=true
NGIT_ARCHIVE_READ_ONLY=true # Default
# Selective backup of critical projects
NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin
NGIT_ARCHIVE_READ_ONLY=true # Default
# Writable mirror (advanced, not typical)
NGIT_ARCHIVE_WHITELIST=npub1alice...
NGIT_ARCHIVE_READ_ONLY=false
```
---
### Logging Configuration ### Logging Configuration
#### `RUST_LOG` #### `RUST_LOG`
+16
View File
@@ -196,6 +196,20 @@ let
''; '';
}; };
archiveReadOnly = mkOption {
type = types.nullOr types.bool;
default = null;
description = ''
Archive read-only mode (relay is read-only sync of archived repositories).
When true:
- NIP-11 includes GRASP-05 in supported_grasps
- NIP-11 curation field describes archive scope
- 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
'';
};
user = mkOption { user = mkOption {
type = types.str; type = types.str;
default = "ngit-grasp-${name}"; default = "ngit-grasp-${name}";
@@ -241,6 +255,8 @@ let
RUST_LOG = cfg.logLevel; RUST_LOG = cfg.logLevel;
} // optionalAttrs (cfg.relayName != null) { } // optionalAttrs (cfg.relayName != null) {
NGIT_RELAY_NAME = cfg.relayName; NGIT_RELAY_NAME = cfg.relayName;
} // optionalAttrs (cfg.archiveReadOnly != null) {
NGIT_ARCHIVE_READ_ONLY = toString cfg.archiveReadOnly;
} // optionalAttrs cfg.metricsEnabled { NGIT_METRICS_ENABLED = "true"; } } // optionalAttrs cfg.metricsEnabled { NGIT_METRICS_ENABLED = "true"; }
// optionalAttrs (cfg.syncBootstrapRelayUrl != null) { // optionalAttrs (cfg.syncBootstrapRelayUrl != null) {
NGIT_SYNC_BOOTSTRAP_RELAY_URL = cfg.syncBootstrapRelayUrl; NGIT_SYNC_BOOTSTRAP_RELAY_URL = cfg.syncBootstrapRelayUrl;
+106 -1
View File
@@ -98,6 +98,13 @@ pub struct ArchiveConfig {
/// ///
/// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
pub whitelist: Vec<ArchiveWhitelistEntry>, pub whitelist: Vec<ArchiveWhitelistEntry>,
/// Read-only archive mode: relay is a read-only sync of archived repositories
///
/// When true, the relay ONLY accepts announcements matching the archive whitelist/all.
/// Announcements listing the relay but not in the whitelist are rejected.
/// When false, the relay operates in GRASP-01 mode for unwhitelisted repos.
pub read_only: bool,
} }
impl ArchiveConfig { impl ArchiveConfig {
@@ -141,6 +148,7 @@ impl Default for ArchiveConfig {
Self { Self {
archive_all: false, archive_all: false,
whitelist: Vec::new(), whitelist: Vec::new(),
read_only: false,
} }
} }
} }
@@ -311,6 +319,12 @@ pub struct Config {
/// Formats: "npub1...", "npub1.../identifier", "identifier" /// Formats: "npub1...", "npub1.../identifier", "identifier"
#[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")]
pub archive_whitelist: String, pub archive_whitelist: String,
/// Archive read-only mode: relay is a read-only sync of archived repositories
/// Defaults to true if archive_all or archive_whitelist is set, false otherwise
/// 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>,
} }
impl Config { impl Config {
@@ -411,12 +425,34 @@ impl Config {
} }
} }
/// Get parsed archive configuration /// Get parsed archive configuration with computed read-only mode
///
/// 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> { pub fn archive_config(&self) -> Result<ArchiveConfig> {
let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?; let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?;
let archive_enabled = self.archive_all || !whitelist.is_empty();
let read_only = match self.archive_read_only {
Some(true) => {
if !archive_enabled {
return Err(anyhow!(
"NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set"
));
}
true
}
Some(false) => false,
None => {
// Default: true if archive mode enabled, false otherwise
archive_enabled
}
};
Ok(ArchiveConfig { Ok(ArchiveConfig {
archive_all: self.archive_all, archive_all: self.archive_all,
whitelist, whitelist,
read_only,
}) })
} }
@@ -452,6 +488,7 @@ impl Config {
naughty_list_expiration_hours: 12, naughty_list_expiration_hours: 12,
archive_all: false, archive_all: false,
archive_whitelist: String::new(), archive_whitelist: String::new(),
archive_read_only: None,
} }
} }
} }
@@ -664,12 +701,14 @@ mod tests {
let config = ArchiveConfig { let config = ArchiveConfig {
archive_all: true, archive_all: true,
whitelist: Vec::new(), whitelist: Vec::new(),
read_only: true,
}; };
assert!(config.enabled()); assert!(config.enabled());
let config = ArchiveConfig { let config = ArchiveConfig {
archive_all: false, archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())], whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())],
read_only: true,
}; };
assert!(config.enabled()); assert!(config.enabled());
} }
@@ -684,6 +723,7 @@ mod tests {
ArchiveWhitelistEntry::Pubkey(test_npub.clone()), ArchiveWhitelistEntry::Pubkey(test_npub.clone()),
ArchiveWhitelistEntry::Identifier("bitcoin-core".into()), ArchiveWhitelistEntry::Identifier("bitcoin-core".into()),
], ],
read_only: false,
}; };
assert!(config.matches(&test_npub, "any-repo")); assert!(config.matches(&test_npub, "any-repo"));
@@ -696,6 +736,7 @@ mod tests {
let config = ArchiveConfig { let config = ArchiveConfig {
archive_all: true, archive_all: true,
whitelist: Vec::new(), whitelist: Vec::new(),
read_only: true,
}; };
assert!(config.matches("npub1alice", "any-repo")); assert!(config.matches("npub1alice", "any-repo"));
@@ -745,4 +786,68 @@ mod tests {
}; };
assert!(config.archive_config().is_err()); assert!(config.archive_config().is_err());
} }
#[test]
fn test_archive_read_only_defaults() {
// Default: false when no archive mode
let config = Config::for_testing();
assert_eq!(config.archive_config().unwrap().read_only, false);
// Default: true when archive_all is set
let config = Config {
archive_all: true,
..Config::for_testing()
};
assert_eq!(config.archive_config().unwrap().read_only, true);
// Default: true when archive_whitelist is set
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = Config {
archive_whitelist: test_npub,
..Config::for_testing()
};
assert_eq!(config.archive_config().unwrap().read_only, true);
}
#[test]
fn test_archive_read_only_explicit() {
// Explicit true with archive_all
let config = Config {
archive_all: true,
archive_read_only: Some(true),
..Config::for_testing()
};
assert_eq!(config.archive_config().unwrap().read_only, true);
// Explicit false with archive_all (unusual but allowed)
let config = Config {
archive_all: true,
archive_read_only: Some(false),
..Config::for_testing()
};
assert_eq!(config.archive_config().unwrap().read_only, false);
// Explicit false without archive mode
let config = Config {
archive_read_only: Some(false),
..Config::for_testing()
};
assert_eq!(config.archive_config().unwrap().read_only, false);
}
#[test]
fn test_archive_read_only_error() {
// Error: true without archive mode
let config = Config {
archive_read_only: Some(true),
..Config::for_testing()
};
assert!(config.archive_config().is_err());
assert!(config
.archive_config()
.unwrap_err()
.to_string()
.contains("requires either"));
}
} }
+85 -2
View File
@@ -56,6 +56,41 @@ pub struct RelayInformationDocument {
impl RelayInformationDocument { impl RelayInformationDocument {
/// Create NIP-11 relay information document from configuration /// Create NIP-11 relay information document from configuration
pub fn from_config(config: &Config) -> Self { pub fn from_config(config: &Config) -> Self {
// Determine if archive mode is enabled
let archive_config = config.archive_config().ok();
let archive_enabled = archive_config
.as_ref()
.map(|ac| ac.enabled())
.unwrap_or(false);
let archive_read_only = archive_config
.as_ref()
.map(|ac| ac.read_only)
.unwrap_or(false);
// Build supported_grasps list
let mut supported_grasps = vec!["GRASP-01".to_string()];
if archive_enabled {
supported_grasps.push("GRASP-05".to_string());
}
supported_grasps.push("GRASP-02".to_string());
// Build curation field for archive read-only mode
let curation = if archive_read_only {
if let Some(ref ac) = archive_config {
if ac.archive_all {
Some("Read-only sync of all repositories found on network".to_string())
} else if !ac.whitelist.is_empty() {
Some("Read-only sync of whitelisted repositories and maintainers".to_string())
} else {
None
}
} else {
None
}
} else {
None
};
Self { Self {
name: config.relay_name(), name: config.relay_name(),
description: config.relay_description.clone(), description: config.relay_description.clone(),
@@ -75,9 +110,9 @@ impl RelayInformationDocument {
icon: Some(format!("https://{}/icon.png", config.domain)), icon: Some(format!("https://{}/icon.png", config.domain)),
// GRASP Extensions // GRASP Extensions
supported_grasps: vec!["GRASP-01".to_string(), "GRASP-02".to_string()], supported_grasps,
repo_acceptance_criteria: "None".to_string(), repo_acceptance_criteria: "None".to_string(),
curation: None, // Not a curated relay - only SPAM prevention via GRASP-01 policy curation,
} }
} }
@@ -90,6 +125,7 @@ impl RelayInformationDocument {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use nostr_sdk::nips::nip19::ToBech32;
#[test] #[test]
fn test_relay_information_document_structure() { fn test_relay_information_document_structure() {
@@ -112,6 +148,7 @@ mod tests {
assert!(doc.supported_nips.contains(&11)); assert!(doc.supported_nips.contains(&11));
assert!(doc.supported_nips.contains(&34)); assert!(doc.supported_nips.contains(&34));
assert!(doc.supported_nips.contains(&77)); assert!(doc.supported_nips.contains(&77));
// Without archive mode, only GRASP-01 and GRASP-02
assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]); assert_eq!(doc.supported_grasps, vec!["GRASP-01", "GRASP-02"]);
assert!(doc.repo_acceptance_criteria.contains("None")); assert!(doc.repo_acceptance_criteria.contains("None"));
assert!(doc.curation.is_none()); assert!(doc.curation.is_none());
@@ -147,4 +184,50 @@ mod tests {
assert_eq!(parsed["supported_grasps"][1], "GRASP-02"); assert_eq!(parsed["supported_grasps"][1], "GRASP-02");
assert_eq!(parsed["icon"], "https://relay.example.com/icon.png"); assert_eq!(parsed["icon"], "https://relay.example.com/icon.png");
} }
#[test]
fn test_nip11_with_archive_mode() {
let mut config = Config::for_testing();
config.domain = "relay.example.com".to_string();
config.relay_name_override = Some("Archive Relay".to_string());
config.archive_all = true;
config.archive_read_only = Some(true);
let doc = RelayInformationDocument::from_config(&config);
// Archive mode enabled: should include GRASP-05
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
);
// Archive read-only: should have curation field
assert!(doc.curation.is_some());
assert!(doc
.curation
.unwrap()
.contains("Read-only sync of all repositories"));
}
#[test]
fn test_nip11_with_whitelist_archive() {
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 = format!("{},bitcoin-core", test_npub);
let doc = RelayInformationDocument::from_config(&config);
// Archive whitelist enabled: should include GRASP-05
assert_eq!(
doc.supported_grasps,
vec!["GRASP-01", "GRASP-05", "GRASP-02"]
);
// Archive read-only defaults to true: should have curation field
assert!(doc.curation.is_some());
assert!(doc
.curation
.unwrap()
.contains("Read-only sync of whitelisted"));
}
} }
+3 -2
View File
@@ -575,9 +575,10 @@ pub async fn create_relay(
if archive_config.enabled() { if archive_config.enabled() {
tracing::info!( tracing::info!(
"GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}", "GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}, read_only={}",
archive_config.archive_all, archive_config.archive_all,
archive_config.whitelist.len() archive_config.whitelist.len(),
archive_config.read_only
); );
} }
+112 -11
View File
@@ -362,10 +362,14 @@ impl RepositoryState {
/// Validate a repository announcement according to GRASP-01 and GRASP-05 /// Validate a repository announcement according to GRASP-01 and GRASP-05
/// ///
/// Returns: /// Returns:
/// - Accept: Announcement lists our service (GRASP-01) /// - Accept: Announcement lists our service (GRASP-01) - unless archive_read_only mode
/// - AcceptArchive: Announcement matches archive config (GRASP-05) /// - AcceptArchive: Announcement matches archive config (GRASP-05)
/// - Reject: Validation failed /// - Reject: Validation failed
/// ///
/// When archive_read_only is true:
/// - ONLY accept announcements matching archive whitelist/all
/// - REJECT announcements listing our service but not in whitelist (read-only sync mode)
///
/// Note: AcceptMaintainer is NOT returned here (requires database access) /// Note: AcceptMaintainer is NOT returned here (requires database access)
pub fn validate_announcement( pub fn validate_announcement(
event: &Event, event: &Event,
@@ -394,23 +398,32 @@ pub fn validate_announcement(
Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)),
}; };
// GRASP-01: Check if announcement lists our service // GRASP-01: Normal mode - accept if announcement lists our service
if announcement.lists_service(domain) { if announcement.lists_service(domain) && !archive_config.read_only {
return AnnouncementResult::Accept; return AnnouncementResult::Accept;
} }
// GRASP-05: Check if announcement matches archive configuration
let npub = announcement.owner_npub(); let npub = announcement.owner_npub();
// GRASP-05: Archive mode - accept if announcement matches whitelist
if archive_config.matches(&npub, &announcement.identifier) { if archive_config.matches(&npub, &announcement.identifier) {
return AnnouncementResult::AcceptArchive; return AnnouncementResult::AcceptArchive;
} }
// Reject: Doesn't list us and not whitelisted // Reject with appropriate error message
AnnouncementResult::Reject(format!( if archive_config.read_only {
"Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \ AnnouncementResult::Reject(format!(
Found clone URLs: {:?}, relays: {:?}", "Archive read-only mode: announcement must match archive whitelist. \
announcement.clone_urls, announcement.relays Repository {}/{} not in whitelist",
)) npub, announcement.identifier
))
} else {
AnnouncementResult::Reject(format!(
"Announcement must list service in both 'clone' and 'relays' tags, or match archive whitelist. \
Found clone URLs: {:?}, relays: {:?}",
announcement.clone_urls, announcement.relays
))
}
} }
/// Validate a repository state announcement according to GRASP-01 /// Validate a repository state announcement according to GRASP-01
@@ -969,6 +982,7 @@ mod tests {
let archive_config = ArchiveConfig { let archive_config = ArchiveConfig {
archive_all: false, archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)], whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)],
read_only: false,
}; };
let result = validate_announcement(&event, "gitnostr.com", &archive_config); let result = validate_announcement(&event, "gitnostr.com", &archive_config);
@@ -994,6 +1008,7 @@ mod tests {
let archive_config = ArchiveConfig { let archive_config = ArchiveConfig {
archive_all: false, archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
read_only: false,
}; };
let result = validate_announcement(&event, "gitnostr.com", &archive_config); let result = validate_announcement(&event, "gitnostr.com", &archive_config);
@@ -1023,6 +1038,7 @@ mod tests {
npub, npub,
identifier: "linux".into(), identifier: "linux".into(),
}], }],
read_only: false,
}; };
let result = validate_announcement(&event, "gitnostr.com", &archive_config); let result = validate_announcement(&event, "gitnostr.com", &archive_config);
@@ -1048,6 +1064,7 @@ mod tests {
let archive_config = ArchiveConfig { let archive_config = ArchiveConfig {
archive_all: true, archive_all: true,
whitelist: Vec::new(), whitelist: Vec::new(),
read_only: false,
}; };
let result = validate_announcement(&event, "gitnostr.com", &archive_config); let result = validate_announcement(&event, "gitnostr.com", &archive_config);
@@ -1073,6 +1090,7 @@ mod tests {
let archive_config = ArchiveConfig { let archive_config = ArchiveConfig {
archive_all: false, archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())], whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
read_only: false,
}; };
let result = validate_announcement(&event, "gitnostr.com", &archive_config); let result = validate_announcement(&event, "gitnostr.com", &archive_config);
@@ -1094,13 +1112,96 @@ mod tests {
vec!["wss://gitnostr.com"], vec!["wss://gitnostr.com"],
); );
// Even with archive config, GRASP-01 Accept takes precedence // With archive_read_only=false, GRASP-01 Accept takes precedence
let archive_config = ArchiveConfig { let archive_config = ArchiveConfig {
archive_all: true, archive_all: true,
whitelist: Vec::new(), whitelist: Vec::new(),
read_only: false,
}; };
let result = validate_announcement(&event, "gitnostr.com", &archive_config); let result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::Accept)); assert!(matches!(result, AnnouncementResult::Accept));
} }
#[test]
fn test_archive_read_only_rejects_non_whitelisted() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
// Create announcement that DOES list our service
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://gitnostr.com/alice/test-repo.git"],
vec!["wss://gitnostr.com"],
);
// 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 result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
#[test]
fn test_archive_read_only_accepts_whitelisted() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
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"],
);
// 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 result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_archive_read_only_with_archive_all() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
// Create announcement that lists our service
let event = create_announcement_event(
&keys,
"any-repo",
vec!["https://gitnostr.com/alice/any-repo.git"],
vec!["wss://gitnostr.com"],
);
// With archive_read_only=true and archive_all=true,
// should accept as AcceptArchive
let archive_config = ArchiveConfig {
archive_all: true,
whitelist: Vec::new(),
read_only: true,
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
} }