feat(grasp-05): implement archive mode for backup/mirror operation

Implements GRASP-05 specification for accepting repository announcements
that don't list this relay, enabling archive, mirror, and backup use cases.

Core Features:
- Three whitelist formats: <npub>, <npub>/<identifier>, <identifier>
- Archive-all mode for complete ecosystem mirrors
- Fail-fast npub validation at startup
- Read-only enforcement (archived repos reject pushes)
- Full GRASP-02 sync (git data + Nostr events)
- Dynamic archive status (no flags/metadata)

Implementation:
- Add ArchiveWhitelistEntry enum with Pubkey/Repository/Identifier variants
- Add ArchiveConfig with validation and matching logic
- Update AnnouncementResult to include AcceptArchive variant
- Refactor validate_announcement() to return AnnouncementResult with archive check
- Update AnnouncementPolicy with catch-all pattern for cleaner code
- Wire archive config through builder and policy layers

Configuration:
- NGIT_ARCHIVE_ALL: Accept all announcements (⚠️ storage risk)
- NGIT_ARCHIVE_WHITELIST: Comma-separated whitelist entries
- Updated docs, .env.example, and nix/module.nix

Testing:
- 28 unit tests for config parsing and whitelist matching
- 7 integration tests for archive mode validation
- All 296 tests passing

Validation Priority:
1. Lists our service → Accept (GRASP-01, read/write)
2. Is maintainer → AcceptMaintainer (multi-maintainer, read/write)
3. Matches archive config → AcceptArchive (GRASP-05, read-only)
4. None of above → Reject

Security Considerations:
- Archive-all mode has storage/bandwidth DoS risk
- Identifier-only format matches any pubkey (use npub/identifier for high-value)
- Invalid npubs cause startup failure (fail-fast)

Documentation:
- Concise explanation focused on rationale
- Reference docs updated with all config options
- README updated to reflect completed feature
- Removed from roadmap, added to compliance section

See docs/explanation/grasp-05-archive.md for details.
This commit is contained in:
DanConwayDev
2026-01-12 17:40:25 +00:00
parent 2b8992631b
commit c29191b1e1
9 changed files with 916 additions and 63 deletions
+25 -1
View File
@@ -165,4 +165,28 @@
# tracked separately and retried after this expiration period
# CLI: --naughty-list-expiration-hours <hours>
# Default: 12
# NGIT_NAUGHTY_LIST_EXPIRATION_HOURS=12
# NGIT_NAUGHTY_LIST_EXPIRATION_HOURS=12
# ============================================================================
# GRASP-05 ARCHIVE MODE
# ============================================================================
# Accept all repository announcements (⚠️ WARNING: Storage/bandwidth risk)
# When enabled, any repository can be mirrored to this relay
# CLI: --archive-all
# Default: false
# NGIT_ARCHIVE_ALL=false
# Whitelist specific repos/pubkeys/identifiers for archiving
# Comma-separated list supporting three formats:
# <npub> - Archive all repos from this pubkey
# <npub>/<identifier> - Archive specific repo
# <identifier> - Archive repos with this identifier (any pubkey)
#
# CLI: --archive-whitelist <list>
# Default: (empty)
# Examples:
# NGIT_ARCHIVE_WHITELIST=npub1alice...
# NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob.../linux
# NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust
# NGIT_ARCHIVE_WHITELIST=
+10 -7
View File
@@ -137,10 +137,17 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f
**See**: [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) and [Purgatory Git Data Sync](docs/explanation/grasp-02-proactive-sync-purgatory-git-data.md)
### GRASP-05 (Archive) - Planned
### GRASP-05 (Archive) ✅
- 🔄 Accept repositories not listing this instance
- 🔄 Backup/mirror mode operation
- ✅ 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)
- ✅ 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.
**See**: [GRASP-05 Archive Mode](docs/explanation/grasp-05-archive.md)
## Roadmap
@@ -223,10 +230,6 @@ This a useful feature of other git servers.
**Future enhancement**: should we periodically scan relays in UserGraspLists to check for announcements that list our relay?
### GRASP-5 Archive
This not only is an important stand alone feature, it can be used to help to test sync features and have a local backup thats easy to deploy (set as bootstrap).
## Technology Stack
- **Rust**: Core language
+148
View File
@@ -0,0 +1,148 @@
# GRASP-05 Archive Mode
**Purpose:** Understand archive/mirror/backup functionality
**Audience:** Operators and developers
---
## What It Does
GRASP-05 enables ngit-grasp to accept repository announcements that **don't list your relay**, allowing you to run an archive, mirror, or backup service.
**Standard GRASP-01:** Announcement must list your service → You host it (read/write)
**GRASP-05 Extension:** Announcement matches your whitelist → You archive it (read-only)
## Why It Exists
### Problem
In GRASP-01 strict mode, you can only host repositories whose maintainers explicitly list your relay. This prevents:
- Creating backup archives of critical projects without maintainer cooperation
- Building comprehensive mirrors of the Nostr Git ecosystem
- Providing disaster recovery for projects that might disappear
### Solution
Archive mode relaxes the "must list service" requirement for whitelisted repositories, enabling passive mirroring while maintaining read-only guarantees.
## How It Works
### Three Whitelist Formats
| Format | Example | Archives |
|--------|---------|----------|
| `<npub>` | `npub1alice...` | All repos from Alice |
| `<npub>/<identifier>` | `npub1bob.../linux` | Only Bob's linux repo |
| `<identifier>` | `bitcoin-core` | Any bitcoin-core repo (⚠️ any pubkey) |
**Configuration:**
```bash
# Specific repos (safest)
NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux,npub1satoshi.../bitcoin
# All repos from trusted maintainers
NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob...
# Archive everything (⚠️ storage risk)
NGIT_ARCHIVE_ALL=true
```
### Validation Priority
Announcements are checked in this order:
1. **Lists your service?** → `Accept` (GRASP-01, read/write)
2. **Is author a maintainer?** → `AcceptMaintainer` (multi-maintainer, read/write)
3. **Matches archive config?** → `AcceptArchive` (GRASP-05, read-only)
4. **None of the above** → `Reject`
This ensures GRASP-01 compliant repos are always writable, even if they match the archive whitelist.
### Storage Model
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)
```
**No flags or metadata** - archive status determined dynamically from config + announcement contents.
### Full Sync
Archived repositories trigger complete GRASP-02 sync:
- ✅ Nostr events (PRs, issues, patches)
- ✅ Git data via purgatory
- ✅ Same validation as hosted repos
Archive mode is a **complete mirror**, not just git-only backup.
## Security Considerations
### 1. Archive-All Mode (Dangerous)
**Don't use `NGIT_ARCHIVE_ALL=true` unless:**
- You have unlimited storage/bandwidth
- You trust the relay network
- You've implemented monitoring
**Attack vector:** Anyone can publish announcements → unlimited storage consumption.
### 2. Identifier-Only Format (Risky)
```bash
NGIT_ARCHIVE_WHITELIST=bitcoin-core # Matches ANY pubkey!
```
Malicious users can publish fake repos with popular identifiers. Use `<npub>/<identifier>` for high-value archives.
### 3. Npub Validation
Invalid npubs → server fails to start (fail-fast). Identifiers aren't validated (any string allowed).
## Operational Guide
### Start Small
```bash
# Day 1: One critical repo
NGIT_ARCHIVE_WHITELIST=npub1torvalds.../linux
# Week 1: Add trusted maintainers
NGIT_ARCHIVE_WHITELIST=npub1alice...,npub1bob...
# Month 1: Consider popular identifiers (with monitoring)
NGIT_ARCHIVE_WHITELIST=npub1alice...,bitcoin-core
```
### Monitor Growth
Watch for:
- Storage consumption rate
- Purgatory git fetch failures
- Bandwidth usage spikes
### Whitelist Changes
**Current:** Static config - edit `.env`, restart server
**Future:** REST API for dynamic management (no restart)
## Comparison: Hosted vs Archived
| Aspect | Hosted (GRASP-01) | Archived (GRASP-05) |
|--------|-------------------|---------------------|
| 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 |
| Use case | Hosting workspace | Backup/mirror |
## Related Documentation
- [Configuration Reference](../reference/configuration.md) - `NGIT_ARCHIVE_*` options
- [GRASP-05 Spec](https://gitworkshop.dev/danconwaydev.com/grasp/05.md) - Protocol specification
- [GRASP-02 Sync](./grasp-02-proactive-sync.md) - How sync works
---
_Part of the [ngit-grasp explanation documentation](./)_
+76
View File
@@ -498,6 +498,82 @@ NGIT_REJECTED_COLD_INDEX_EXPIRY_SECS=1209600
---
### GRASP-05 Archive Configuration
These options enable archive/mirror/backup mode per the GRASP-05 specification.
#### `NGIT_ARCHIVE_ALL`
**Description:** Accept all repository announcements regardless of whether they list this instance
**Type:** Boolean
**Default:** `false`
**Required:** No
**Examples:**
```bash
# Enable archive-all mode (⚠️ WARNING: Storage risk)
NGIT_ARCHIVE_ALL=true
# Disable (default - GRASP-01 strict mode)
NGIT_ARCHIVE_ALL=false
```
**Security Warning:** When enabled, any repository can be mirrored to this relay, potentially causing storage and bandwidth exhaustion. Only enable if you have unlimited resources and trust the relay network.
**Notes:**
- Archived repositories are read-only (pushes rejected)
- Full sync enabled (both git data and Nostr events)
- Takes precedence over whitelist (accepts everything)
---
#### `NGIT_ARCHIVE_WHITELIST`
**Description:** Comma-separated list of repositories/pubkeys/identifiers to archive
**Type:** String (comma-separated)
**Default:** (empty)
**Required:** No
**Formats:**
- `<npub>` - Archive all repos from this pubkey
- `<npub>/<identifier>` - Archive specific repo from specific pubkey
- `<identifier>` - Archive repos with this identifier from any pubkey
**Examples:**
```bash
# Archive all repos from Alice
NGIT_ARCHIVE_WHITELIST=npub1alice23
# Archive specific repos
NGIT_ARCHIVE_WHITELIST=npub1alice23/linux,npub1bob23/bitcoin-core
# Archive by identifier (any pubkey)
NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust
# Mixed formats
NGIT_ARCHIVE_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core
```
**Validation:**
- Npub entries are validated at startup (invalid npub = server fails to start)
- Identifier entries accept any string
- Whitespace is trimmed
- Empty entries are ignored
**Security Notes:**
- Identifier-only format (`bitcoin-core`) matches ANY pubkey
- Use `npub/identifier` format for high-value archives
- Whitelist is static (restart required to change)
- Future: Dynamic management via API
---
### Logging Configuration
#### `RUST_LOG`
+21
View File
@@ -177,6 +177,25 @@ let
description = "Hours before removing relay from naughty list";
};
archiveAll = mkOption {
type = types.bool;
default = false;
description = ''
Enable GRASP-05 archive mode: accept all repository announcements.
WARNING: Storage and bandwidth risk.
'';
};
archiveWhitelist = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "npub1alice..." "npub1bob.../linux" "bitcoin-core" ];
description = ''
GRASP-05 archive whitelist entries.
Formats: <npub>, <npub>/<identifier>, <identifier>
'';
};
user = mkOption {
type = types.str;
default = "ngit-grasp-${name}";
@@ -217,6 +236,8 @@ let
toString cfg.rejectedColdIndexExpirySecs;
NGIT_NAUGHTY_LIST_EXPIRATION_HOURS =
toString cfg.naughtyListExpirationHours;
NGIT_ARCHIVE_ALL = toString cfg.archiveAll;
NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist;
RUST_LOG = cfg.logLevel;
} // optionalAttrs (cfg.relayName != null) {
NGIT_RELAY_NAME = cfg.relayName;
+320 -1
View File
@@ -1,10 +1,150 @@
use anyhow::{Context, Result};
use anyhow::{anyhow, Context, Result};
use clap::{Parser, ValueEnum};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// GRASP-05 Archive whitelist entry
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ArchiveWhitelistEntry {
/// Archive all repos from this pubkey: "npub1..."
Pubkey(String),
/// Archive specific repo: "npub1.../identifier"
Repository { npub: String, identifier: String },
/// Archive any repo with this identifier: "identifier"
Identifier(String),
}
impl ArchiveWhitelistEntry {
/// Parse a whitelist entry from string
///
/// Formats:
/// - "npub1..." -> Pubkey
/// - "npub1.../identifier" -> Repository
/// - "identifier" -> Identifier
///
/// Validates npub format at parse time (fail fast)
pub fn parse(s: &str) -> Result<Self> {
let trimmed = s.trim();
if trimmed.contains('/') {
// Format: npub1.../identifier
let parts: Vec<&str> = trimmed.split('/').collect();
if parts.len() != 2 {
return Err(anyhow!(
"Invalid whitelist entry format '{}'. Expected 'npub/identifier'",
s
));
}
let npub = parts[0];
let identifier = parts[1];
// Validate npub format (fail fast)
if !npub.starts_with("npub1") {
return Err(anyhow!(
"Invalid whitelist entry '{}'. First part must be npub",
s
));
}
PublicKey::from_bech32(npub)
.context(format!("Invalid npub in whitelist entry '{}'", s))?;
Ok(Self::Repository {
npub: npub.to_string(),
identifier: identifier.to_string(),
})
} else if trimmed.starts_with("npub1") {
// Format: npub1...
// Validate npub format (fail fast)
PublicKey::from_bech32(trimmed)
.context(format!("Invalid npub in whitelist entry '{}'", s))?;
Ok(Self::Pubkey(trimmed.to_string()))
} else {
// Format: identifier
Ok(Self::Identifier(trimmed.to_string()))
}
}
/// Check if this entry matches the given npub and identifier
pub fn matches(&self, npub: &str, identifier: &str) -> bool {
match self {
Self::Pubkey(p) => npub == p,
Self::Repository {
npub: p,
identifier: i,
} => npub == p && identifier == i,
Self::Identifier(i) => identifier == i,
}
}
}
/// GRASP-05 Archive mode configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveConfig {
/// Accept all repository announcements (no filtering)
///
/// WARNING: Setting this to true allows anyone to mirror any repository
/// to this relay, potentially causing storage/bandwidth exhaustion.
pub archive_all: bool,
/// Whitelist entries for selective archiving
///
/// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode).
pub whitelist: Vec<ArchiveWhitelistEntry>,
}
impl ArchiveConfig {
/// Check if GRASP-05 is enabled (either archive_all or non-empty whitelist)
pub fn enabled(&self) -> bool {
self.archive_all || !self.whitelist.is_empty()
}
/// Check if an announcement matches the archive configuration
///
/// Returns true if:
/// - archive_all is true, OR
/// - announcement matches any whitelist entry
pub fn matches(&self, npub: &str, identifier: &str) -> bool {
if self.archive_all {
return true;
}
self.whitelist
.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 {
fn default() -> Self {
Self {
archive_all: false,
whitelist: Vec::new(),
}
}
}
/// Database backend type for the relay
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)]
#[serde(rename_all = "lowercase")]
@@ -162,6 +302,15 @@ pub struct Config {
/// tracked separately and retried after this expiration period.
#[arg(long, env = "NGIT_NAUGHTY_LIST_EXPIRATION_HOURS", default_value_t = 12)]
pub naughty_list_expiration_hours: u64,
/// Enable GRASP-05 archive mode: accept all announcements regardless of listing (WARNING: storage risk)
#[arg(long, env = "NGIT_ARCHIVE_ALL", default_value_t = false)]
pub archive_all: bool,
/// GRASP-05 archive whitelist: comma-separated list of npub/identifier/npub/identifier entries
/// Formats: "npub1...", "npub1.../identifier", "identifier"
#[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")]
pub archive_whitelist: String,
}
impl Config {
@@ -262,6 +411,15 @@ impl Config {
}
}
/// Get parsed archive configuration
pub fn archive_config(&self) -> Result<ArchiveConfig> {
let whitelist = ArchiveConfig::parse_whitelist(&self.archive_whitelist)?;
Ok(ArchiveConfig {
archive_all: self.archive_all,
whitelist,
})
}
/// Create config for testing
#[cfg(test)]
pub fn for_testing() -> Self {
@@ -292,6 +450,8 @@ impl Config {
rejected_hot_cache_duration_secs: 120,
rejected_cold_index_expiry_secs: 604800,
naughty_list_expiration_hours: 12,
archive_all: false,
archive_whitelist: String::new(),
}
}
}
@@ -426,4 +586,163 @@ mod tests {
assert_eq!(config.metrics_connection_per_ip_abuse_threshold, 50);
assert_eq!(config.metrics_top_n_repos, 25);
}
#[test]
fn test_parse_whitelist_entry_pubkey() {
// 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 {
assert_eq!(npub, test_npub);
}
}
#[test]
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 {
assert_eq!(npub, test_npub);
assert_eq!(identifier, "linux");
}
}
#[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 {
assert_eq!(id, "bitcoin-core");
}
}
#[test]
fn test_parse_whitelist_entry_invalid_npub() {
let result = ArchiveWhitelistEntry::parse("npub1invalid");
assert!(result.is_err());
}
#[test]
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());
assert!(entry.matches(&test_npub, "any-identifier"));
assert!(!entry.matches("npub1different", "any-identifier"));
}
#[test]
fn test_whitelist_entry_matches_repository() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let entry = ArchiveWhitelistEntry::Repository {
npub: test_npub.clone(),
identifier: "linux".to_string(),
};
assert!(entry.matches(&test_npub, "linux"));
assert!(!entry.matches(&test_npub, "bitcoin"));
assert!(!entry.matches("npub1different", "linux"));
}
#[test]
fn test_whitelist_entry_matches_identifier() {
let entry = ArchiveWhitelistEntry::Identifier("bitcoin-core".to_string());
assert!(entry.matches("npub1alice", "bitcoin-core"));
assert!(entry.matches("npub1bob", "bitcoin-core"));
assert!(!entry.matches("npub1alice", "other-repo"));
}
#[test]
fn test_archive_config_enabled() {
let config = ArchiveConfig::default();
assert!(!config.enabled());
let config = ArchiveConfig {
archive_all: true,
whitelist: Vec::new(),
};
assert!(config.enabled());
let config = ArchiveConfig {
archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Identifier("test".into())],
};
assert!(config.enabled());
}
#[test]
fn test_archive_config_matches() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = ArchiveConfig {
archive_all: false,
whitelist: vec![
ArchiveWhitelistEntry::Pubkey(test_npub.clone()),
ArchiveWhitelistEntry::Identifier("bitcoin-core".into()),
],
};
assert!(config.matches(&test_npub, "any-repo"));
assert!(config.matches("npub1bob", "bitcoin-core"));
assert!(!config.matches("npub1bob", "other-repo"));
}
#[test]
fn test_archive_config_matches_archive_all() {
let config = ArchiveConfig {
archive_all: true,
whitelist: Vec::new(),
};
assert!(config.matches("npub1alice", "any-repo"));
assert!(config.matches("npub1bob", "other-repo"));
}
#[test]
fn test_parse_whitelist_empty() {
let whitelist = ArchiveConfig::parse_whitelist("").unwrap();
assert!(whitelist.is_empty());
let whitelist = ArchiveConfig::parse_whitelist(" ").unwrap();
assert!(whitelist.is_empty());
}
#[test]
fn test_parse_whitelist_multiple() {
let keys1 = Keys::generate();
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!(
"{},bitcoin-core,{}/linux",
test_npub1, test_npub2
))
.unwrap();
assert_eq!(whitelist.len(), 3);
}
#[test]
fn test_archive_config_parsing() {
let keys = Keys::generate();
let test_npub = keys.public_key().to_bech32().unwrap();
let config = Config {
archive_whitelist: format!("{},bitcoin-core", test_npub),
..Config::for_testing()
};
let archive_config = config.archive_config().unwrap();
assert_eq!(archive_config.whitelist.len(), 2);
}
#[test]
fn test_archive_config_invalid_npub() {
let config = Config {
archive_whitelist: "npub1invalid".to_string(),
..Config::for_testing()
};
assert!(config.archive_config().is_err());
}
}
+50 -3
View File
@@ -55,10 +55,11 @@ impl Nip34WritePolicy {
database: SharedDatabase,
git_data_path: impl Into<std::path::PathBuf>,
purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
archive_config: crate::config::ArchiveConfig,
) -> Self {
let ctx = PolicyContext::new(domain, database, git_data_path, purgatory);
Self {
announcement_policy: AnnouncementPolicy::new(ctx.clone()),
announcement_policy: AnnouncementPolicy::new(ctx.clone(), archive_config),
state_policy: StatePolicy::new(ctx.clone()),
pr_event_policy: PrEventPolicy::new(ctx.clone()),
related_event_policy: RelatedEventPolicy::new(ctx.clone()),
@@ -147,6 +148,34 @@ impl Nip34WritePolicy {
}
}
}
AnnouncementResult::AcceptArchive => {
// GRASP-05: Archive mode - accept announcement but don't create bare repository
match RepositoryAnnouncement::from_event(event.clone()) {
Ok(announcement) => {
tracing::info!(
"Accepted archive announcement {} for {}/{} (GRASP-05 read-only mirror)",
event_id_str,
announcement.owner_npub(),
announcement.identifier
);
// Don't create bare repository for archived announcements
// Check purgatory for state events that might now be authorized
self.check_purgatory_state_events_for_identifier(&announcement.identifier)
.await;
WritePolicyResult::Accept
}
Err(e) => {
tracing::warn!(
"Failed to parse archive announcement {}: {}",
event_id_str,
e
);
WritePolicyResult::reject(format!("Failed to parse announcement: {}", e))
}
}
}
AnnouncementResult::Reject(reason) => {
tracing::warn!(
"Rejected repository announcement {}: {}",
@@ -539,9 +568,27 @@ 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))?;
if archive_config.enabled() {
tracing::info!(
"GRASP-05 archive mode enabled: archive_all={}, whitelist_entries={}",
archive_config.archive_all,
archive_config.whitelist.len()
);
}
// Create write policy with purgatory integration
let write_policy =
Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path, purgatory);
let write_policy = Nip34WritePolicy::new(
&config.domain,
database.clone(),
&git_data_path,
purgatory,
archive_config,
);
let relay = LocalRelayBuilder::default()
.database(database.clone())
+241 -34
View File
@@ -359,13 +359,24 @@ impl RepositoryState {
}
}
/// Validate a repository announcement according to GRASP-01
/// Validate a repository announcement according to GRASP-01 and GRASP-05
///
/// Returns Ok(()) if valid, Err with reason if invalid.
pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> {
/// Returns:
/// - Accept: Announcement lists our service (GRASP-01)
/// - AcceptArchive: Announcement matches archive config (GRASP-05)
/// - Reject: Validation failed
///
/// Note: AcceptMaintainer is NOT returned here (requires database access)
pub fn validate_announcement(
event: &Event,
domain: &str,
archive_config: &crate::config::ArchiveConfig,
) -> crate::nostr::policy::AnnouncementResult {
use crate::nostr::policy::AnnouncementResult;
// Must be kind 30617
if event.kind != Kind::GitRepoAnnouncement {
return Err(anyhow!(
return AnnouncementResult::Reject(format!(
"Invalid kind: expected {}",
Kind::GitRepoAnnouncement
));
@@ -374,24 +385,32 @@ pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> {
// Must have identifier
let has_identifier = event.tags.iter().any(|t| t.kind() == TagKind::d());
if !has_identifier {
return Err(anyhow!("Missing required 'd' tag (identifier)"));
return AnnouncementResult::Reject("Missing required 'd' tag (identifier)".to_string());
}
// Parse full announcement to validate structure
let announcement = RepositoryAnnouncement::from_event(event.clone())?;
let announcement = match RepositoryAnnouncement::from_event(event.clone()) {
Ok(a) => a,
Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)),
};
// GRASP-01: MUST reject announcements that do not list the service
// in both `clone` and `relays` tags unless implementing GRASP-05
if !announcement.lists_service(domain) {
return Err(anyhow!(
"Announcement must list service in both 'clone' and 'relays' tags. \
Found clone URLs: {:?}, relays: {:?}",
announcement.clone_urls,
announcement.relays
));
// GRASP-01: Check if announcement lists our service
if announcement.lists_service(domain) {
return AnnouncementResult::Accept;
}
Ok(())
// GRASP-05: Check if announcement matches archive configuration
let npub = announcement.owner_npub();
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
))
}
/// Validate a repository state announcement according to GRASP-01
@@ -529,6 +548,9 @@ mod tests {
#[test]
fn test_validate_announcement_success() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
@@ -537,12 +559,15 @@ mod tests {
vec!["wss://gitnostr.com"],
);
let result = validate_announcement(&event, "gitnostr.com");
assert!(result.is_ok());
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_validate_announcement_missing_clone() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
@@ -551,13 +576,19 @@ mod tests {
vec!["wss://gitnostr.com"],
);
let result = validate_announcement(&event, "gitnostr.com");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("clone"));
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
if let AnnouncementResult::Reject(reason) = result {
assert!(reason.contains("clone"));
} else {
panic!("Expected Reject, got {:?}", result);
}
}
#[test]
fn test_validate_announcement_missing_relay() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
@@ -566,13 +597,19 @@ mod tests {
vec![], // No relays
);
let result = validate_announcement(&event, "gitnostr.com");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("relays"));
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
if let AnnouncementResult::Reject(reason) = result {
assert!(reason.contains("relays"));
} else {
panic!("Expected Reject, got {:?}", result);
}
}
#[test]
fn test_validate_announcement_wrong_domain() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
@@ -581,8 +618,8 @@ mod tests {
vec!["wss://other-service.com"],
);
let result = validate_announcement(&event, "gitnostr.com");
assert!(result.is_err());
let result = validate_announcement(&event, "gitnostr.com", &ArchiveConfig::default());
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
#[test]
@@ -805,6 +842,9 @@ mod tests {
#[test]
fn test_validate_announcement_with_trailing_slash_in_relay() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
@@ -814,12 +854,16 @@ mod tests {
);
// Should accept despite trailing slash mismatch
let result = validate_announcement(&event, "git.shakespeare.diy");
assert!(result.is_ok());
let result =
validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_validate_announcement_with_trailing_slash_in_clone_url() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
@@ -829,12 +873,16 @@ mod tests {
);
// Should accept despite trailing slash mismatch
let result = validate_announcement(&event, "git.shakespeare.diy");
assert!(result.is_ok());
let result =
validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_validate_announcement_with_trailing_slash_in_both() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
@@ -844,12 +892,16 @@ mod tests {
);
// Should accept with trailing slashes in both
let result = validate_announcement(&event, "git.shakespeare.diy");
assert!(result.is_ok());
let result =
validate_announcement(&event, "git.shakespeare.diy", &ArchiveConfig::default());
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
fn test_validate_announcement_domain_with_trailing_slash() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
let event = create_announcement_event(
&keys,
@@ -859,8 +911,8 @@ mod tests {
);
// Should accept even when domain parameter has trailing slash
let result = validate_announcement(&event, "gitnostr.com/");
assert!(result.is_ok());
let result = validate_announcement(&event, "gitnostr.com/", &ArchiveConfig::default());
assert!(matches!(result, AnnouncementResult::Accept));
}
#[test]
@@ -896,4 +948,159 @@ mod tests {
assert!(announcement.has_relay("example.com"));
assert!(announcement.has_relay("example.com/"));
}
#[test]
fn test_validate_announcement_archive_mode_npub() {
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 does NOT list our service
let event = create_announcement_event(
&keys,
"test-repo",
vec!["https://other-service.com/alice/test-repo.git"],
vec!["wss://other-service.com"],
);
// Create archive config that whitelists this npub
let archive_config = ArchiveConfig {
archive_all: false,
whitelist: vec![ArchiveWhitelistEntry::Pubkey(npub)],
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_validate_announcement_archive_mode_identifier() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
// Create announcement that does NOT list our service
let event = create_announcement_event(
&keys,
"bitcoin-core",
vec!["https://other-service.com/alice/bitcoin-core.git"],
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())],
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_validate_announcement_archive_mode_repository() {
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 does NOT list our service
let event = create_announcement_event(
&keys,
"linux",
vec!["https://other-service.com/alice/linux.git"],
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(),
}],
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_validate_announcement_archive_all() {
use crate::config::ArchiveConfig;
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
// Create announcement that does NOT list our service
let event = create_announcement_event(
&keys,
"any-repo",
vec!["https://other-service.com/alice/any-repo.git"],
vec!["wss://other-service.com"],
);
// Create archive config with archive_all enabled
let archive_config = ArchiveConfig {
archive_all: true,
whitelist: Vec::new(),
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::AcceptArchive));
}
#[test]
fn test_validate_announcement_reject_not_in_whitelist() {
use crate::config::{ArchiveConfig, ArchiveWhitelistEntry};
use crate::nostr::policy::AnnouncementResult;
let keys = create_test_keys();
// Create announcement that does NOT list our service
let event = create_announcement_event(
&keys,
"other-repo",
vec!["https://other-service.com/alice/other-repo.git"],
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())],
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::Reject(_)));
}
#[test]
fn test_validate_announcement_grasp01_takes_precedence() {
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"],
);
// Even with archive config, GRASP-01 Accept takes precedence
let archive_config = ArchiveConfig {
archive_all: true,
whitelist: Vec::new(),
};
let result = validate_announcement(&event, "gitnostr.com", &archive_config);
assert!(matches!(result, AnnouncementResult::Accept));
}
}
+25 -17
View File
@@ -5,15 +5,18 @@
use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
use super::PolicyContext;
use crate::config::ArchiveConfig;
use crate::nostr::events::{validate_announcement, RepositoryAnnouncement};
/// Result of announcement policy evaluation
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq)]
pub enum AnnouncementResult {
/// Accept: Event passes validation
/// Accept: Event lists our service (GRASP-01 compliant)
Accept,
/// Accept as maintainer: Event accepted via maintainer exception
/// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer)
AcceptMaintainer,
/// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only)
AcceptArchive,
/// Reject: Event fails validation with reason
Reject(String),
}
@@ -22,31 +25,34 @@ pub enum AnnouncementResult {
#[derive(Clone)]
pub struct AnnouncementPolicy {
ctx: PolicyContext,
archive_config: ArchiveConfig,
}
impl AnnouncementPolicy {
pub fn new(ctx: PolicyContext) -> Self {
Self { ctx }
pub fn new(ctx: PolicyContext, archive_config: ArchiveConfig) -> Self {
Self {
ctx,
archive_config,
}
}
/// Validate a repository announcement event
///
/// Returns `Accept` if the announcement lists the service properly,
/// `AcceptMaintainer` if accepted via maintainer exception,
/// `AcceptArchive` if accepted via GRASP-05 archive config,
/// or `Reject` with reason.
pub async fn validate(&self, event: &Event) -> AnnouncementResult {
// First, try normal validation (announcement lists service)
match validate_announcement(event, &self.ctx.domain) {
Ok(_) => AnnouncementResult::Accept,
Err(validation_err) => {
// Validation failed - check if this is a recursive maintainer announcement
// GRASP-01 Exception: Accept announcements from recursive maintainers
// even without listing the service, for chain discovery and GRASP-02 sync
// First, try validation (GRASP-01 + GRASP-05)
let validation_result =
validate_announcement(event, &self.ctx.domain, &self.archive_config);
// Try to parse the announcement to get identifier
match validation_result {
AnnouncementResult::Reject(reason) => {
// Validation failed - check maintainer exception
// GRASP-01 Exception: Accept announcements from recursive maintainers
match RepositoryAnnouncement::from_event(event.clone()) {
Ok(announcement) => {
// Check if author is listed as maintainer in any existing announcement
match self
.is_maintainer_in_any_announcement(
&announcement.identifier,
@@ -55,16 +61,18 @@ impl AnnouncementPolicy {
.await
{
Ok(true) => AnnouncementResult::AcceptMaintainer,
Ok(false) => AnnouncementResult::Reject(validation_err.to_string()),
Ok(false) => AnnouncementResult::Reject(reason),
Err(_) => {
// Fail-secure: reject on database errors
AnnouncementResult::Reject(validation_err.to_string())
AnnouncementResult::Reject(reason)
}
}
}
Err(_) => AnnouncementResult::Reject(validation_err.to_string()),
Err(_) => AnnouncementResult::Reject(reason),
}
}
// Accept, AcceptArchive, or AcceptMaintainer - return as-is
result => result,
}
}