mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
@@ -190,3 +190,16 @@
|
||||
# NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob.../linux
|
||||
# NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust
|
||||
# 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=
|
||||
@@ -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
|
||||
- ✅ 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
|
||||
- ✅ 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)
|
||||
|
||||
|
||||
@@ -35,14 +35,17 @@ Archive mode relaxes the "must list service" requirement for whitelisted reposit
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
# Specific repos (safest)
|
||||
# Specific repos (safest) - read-only by default
|
||||
NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin
|
||||
# NGIT_ARCHIVE_READ_ONLY defaults to true
|
||||
|
||||
# All repos from trusted maintainers
|
||||
NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob...
|
||||
# NGIT_ARCHIVE_READ_ONLY defaults to true
|
||||
|
||||
# Archive everything (⚠️ storage risk)
|
||||
NGIT_ARCHIVE_ALL=true
|
||||
# NGIT_ARCHIVE_READ_ONLY defaults to true
|
||||
```
|
||||
|
||||
### Validation Priority
|
||||
@@ -63,11 +66,21 @@ Archived repos use the same directory structure as hosted repos:
|
||||
<git_data_path>/
|
||||
npub1alice.../
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
Archived repositories trigger complete GRASP-02 sync:
|
||||
@@ -129,12 +142,14 @@ Watch for:
|
||||
|
||||
## 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 |
|
||||
| Git pushes | ✅ Accepted | ❌ Rejected (read-only) |
|
||||
| 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 |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
@@ -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
|
||||
|
||||
#### `RUST_LOG`
|
||||
|
||||
@@ -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 {
|
||||
type = types.str;
|
||||
default = "ngit-grasp-${name}";
|
||||
@@ -241,6 +255,8 @@ let
|
||||
RUST_LOG = cfg.logLevel;
|
||||
} // optionalAttrs (cfg.relayName != null) {
|
||||
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.syncBootstrapRelayUrl != null) {
|
||||
NGIT_SYNC_BOOTSTRAP_RELAY_URL = cfg.syncBootstrapRelayUrl;
|
||||
|
||||
+106
-1
@@ -98,6 +98,13 @@ pub struct ArchiveConfig {
|
||||
///
|
||||
/// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
|
||||
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 {
|
||||
@@ -141,6 +148,7 @@ impl Default for ArchiveConfig {
|
||||
Self {
|
||||
archive_all: false,
|
||||
whitelist: Vec::new(),
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,6 +319,12 @@ pub struct Config {
|
||||
/// Formats: "npub1...", "npub1.../identifier", "identifier"
|
||||
#[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")]
|
||||
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 {
|
||||
@@ -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> {
|
||||
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 {
|
||||
archive_all: self.archive_all,
|
||||
whitelist,
|
||||
read_only,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -452,6 +488,7 @@ impl Config {
|
||||
naughty_list_expiration_hours: 12,
|
||||
archive_all: false,
|
||||
archive_whitelist: String::new(),
|
||||
archive_read_only: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -664,12 +701,14 @@ mod tests {
|
||||
let config = ArchiveConfig {
|
||||
archive_all: true,
|
||||
whitelist: Vec::new(),
|
||||
read_only: true,
|
||||
};
|
||||
assert!(config.enabled());
|
||||
|
||||
let config = ArchiveConfig {
|
||||
archive_all: false,
|
||||
whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())],
|
||||
read_only: true,
|
||||
};
|
||||
assert!(config.enabled());
|
||||
}
|
||||
@@ -684,6 +723,7 @@ mod tests {
|
||||
ArchiveWhitelistEntry::Pubkey(test_npub.clone()),
|
||||
ArchiveWhitelistEntry::Identifier("bitcoin-core".into()),
|
||||
],
|
||||
read_only: false,
|
||||
};
|
||||
|
||||
assert!(config.matches(&test_npub, "any-repo"));
|
||||
@@ -696,6 +736,7 @@ mod tests {
|
||||
let config = ArchiveConfig {
|
||||
archive_all: true,
|
||||
whitelist: Vec::new(),
|
||||
read_only: true,
|
||||
};
|
||||
|
||||
assert!(config.matches("npub1alice", "any-repo"));
|
||||
@@ -745,4 +786,68 @@ mod tests {
|
||||
};
|
||||
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
@@ -56,6 +56,41 @@ pub struct RelayInformationDocument {
|
||||
impl RelayInformationDocument {
|
||||
/// Create NIP-11 relay information document from configuration
|
||||
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 {
|
||||
name: config.relay_name(),
|
||||
description: config.relay_description.clone(),
|
||||
@@ -75,9 +110,9 @@ impl RelayInformationDocument {
|
||||
icon: Some(format!("https://{}/icon.png", config.domain)),
|
||||
|
||||
// GRASP Extensions
|
||||
supported_grasps: vec!["GRASP-01".to_string(), "GRASP-02".to_string()],
|
||||
supported_grasps,
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_sdk::nips::nip19::ToBech32;
|
||||
|
||||
#[test]
|
||||
fn test_relay_information_document_structure() {
|
||||
@@ -112,6 +148,7 @@ mod tests {
|
||||
assert!(doc.supported_nips.contains(&11));
|
||||
assert!(doc.supported_nips.contains(&34));
|
||||
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!(doc.repo_acceptance_criteria.contains("None"));
|
||||
assert!(doc.curation.is_none());
|
||||
@@ -147,4 +184,50 @@ mod tests {
|
||||
assert_eq!(parsed["supported_grasps"][1], "GRASP-02");
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,9 +575,10 @@ pub async fn create_relay(
|
||||
|
||||
if archive_config.enabled() {
|
||||
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.whitelist.len()
|
||||
archive_config.whitelist.len(),
|
||||
archive_config.read_only
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+112
-11
@@ -362,10 +362,14 @@ impl RepositoryState {
|
||||
/// Validate a repository announcement according to GRASP-01 and GRASP-05
|
||||
///
|
||||
/// 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)
|
||||
/// - 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)
|
||||
pub fn validate_announcement(
|
||||
event: &Event,
|
||||
@@ -394,23 +398,32 @@ pub fn validate_announcement(
|
||||
Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)),
|
||||
};
|
||||
|
||||
// GRASP-01: Check if announcement lists our service
|
||||
if announcement.lists_service(domain) {
|
||||
// GRASP-01: Normal mode - accept if announcement lists our service
|
||||
if announcement.lists_service(domain) && !archive_config.read_only {
|
||||
return AnnouncementResult::Accept;
|
||||
}
|
||||
|
||||
// GRASP-05: Check if announcement matches archive configuration
|
||||
let npub = announcement.owner_npub();
|
||||
|
||||
// GRASP-05: Archive mode - accept if announcement matches whitelist
|
||||
if archive_config.matches(&npub, &announcement.identifier) {
|
||||
return AnnouncementResult::AcceptArchive;
|
||||
}
|
||||
|
||||
// Reject: Doesn't list us and not whitelisted
|
||||
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
|
||||
))
|
||||
// Reject with appropriate error message
|
||||
if archive_config.read_only {
|
||||
AnnouncementResult::Reject(format!(
|
||||
"Archive read-only mode: announcement must match archive whitelist. \
|
||||
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
|
||||
@@ -969,6 +982,7 @@ mod tests {
|
||||
let archive_config = ArchiveConfig {
|
||||
archive_all: false,
|
||||
whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)],
|
||||
read_only: false,
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
@@ -994,6 +1008,7 @@ mod tests {
|
||||
let archive_config = ArchiveConfig {
|
||||
archive_all: false,
|
||||
whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
|
||||
read_only: false,
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
@@ -1023,6 +1038,7 @@ mod tests {
|
||||
npub,
|
||||
identifier: "linux".into(),
|
||||
}],
|
||||
read_only: false,
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
@@ -1048,6 +1064,7 @@ mod tests {
|
||||
let archive_config = ArchiveConfig {
|
||||
archive_all: true,
|
||||
whitelist: Vec::new(),
|
||||
read_only: false,
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
@@ -1073,6 +1090,7 @@ mod tests {
|
||||
let archive_config = ArchiveConfig {
|
||||
archive_all: false,
|
||||
whitelist: vec![ArchiveWhitelistEntry::Identifier("bitcoin-core".into())],
|
||||
read_only: false,
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
@@ -1094,13 +1112,96 @@ mod tests {
|
||||
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 {
|
||||
archive_all: true,
|
||||
whitelist: Vec::new(),
|
||||
read_only: false,
|
||||
};
|
||||
|
||||
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user