upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/nostr/policy/announcement.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 15:42:00 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 15:42:00 +0000
commit819866330c7e2f535a155d1d7efaf2e12dc15dc2 (patch)
treed84c8361811544aad9cad089c0358b9028c8fb80 /src/nostr/policy/announcement.rs
parentfd0c87c787d0626b3546fa571541c9c809711821 (diff)
refactor: split Nip34WritePolicy into focused sub-policies
Split the ~900 line Nip34WritePolicy into focused sub-policies for improved testability and maintainability: - AnnouncementPolicy - Repository announcement validation - StatePolicy - State event validation + ref alignment - PrEventPolicy - PR/PR Update validation - RelatedEventPolicy - Forward/backward reference checking The main Nip34WritePolicy now delegates to these sub-policies via a shared PolicyContext that provides domain, database, and git_data_path. Also updates: - README.md: Accurate project structure reflecting actual implementation - docs/learnings: Marks this technical debt item as complete
Diffstat (limited to 'src/nostr/policy/announcement.rs')
-rw-r--r--src/nostr/policy/announcement.rs157
1 files changed, 157 insertions, 0 deletions
diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs
new file mode 100644
index 0000000..8d30baf
--- /dev/null
+++ b/src/nostr/policy/announcement.rs
@@ -0,0 +1,157 @@
1/// Announcement Policy - Repository announcement validation
2///
3/// Handles validation of NIP-34 repository announcements (kind 30617)
4/// according to GRASP-01 specification.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::nostr::events::{
9 validate_announcement, RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT,
10};
11
12/// Result of announcement policy evaluation
13#[derive(Debug)]
14pub enum AnnouncementResult {
15 /// Accept: Event passes validation
16 Accept,
17 /// Accept as maintainer: Event accepted via maintainer exception
18 AcceptMaintainer,
19 /// Reject: Event fails validation with reason
20 Reject(String),
21}
22
23/// Policy for validating repository announcements
24#[derive(Clone)]
25pub struct AnnouncementPolicy {
26 ctx: PolicyContext,
27}
28
29impl AnnouncementPolicy {
30 pub fn new(ctx: PolicyContext) -> Self {
31 Self { ctx }
32 }
33
34 /// Validate a repository announcement event
35 ///
36 /// Returns `Accept` if the announcement lists the service properly,
37 /// `AcceptMaintainer` if accepted via maintainer exception,
38 /// or `Reject` with reason.
39 pub async fn validate(&self, event: &Event) -> AnnouncementResult {
40 // First, try normal validation (announcement lists service)
41 match validate_announcement(event, &self.ctx.domain) {
42 Ok(_) => AnnouncementResult::Accept,
43 Err(validation_err) => {
44 // Validation failed - check if this is a recursive maintainer announcement
45 // GRASP-01 Exception: Accept announcements from recursive maintainers
46 // even without listing the service, for chain discovery and GRASP-02 sync
47
48 // Try to parse the announcement to get identifier
49 match RepositoryAnnouncement::from_event(event.clone()) {
50 Ok(announcement) => {
51 // Check if author is listed as maintainer in any existing announcement
52 match self
53 .is_maintainer_in_any_announcement(
54 &announcement.identifier,
55 &event.pubkey,
56 )
57 .await
58 {
59 Ok(true) => AnnouncementResult::AcceptMaintainer,
60 Ok(false) => AnnouncementResult::Reject(validation_err.to_string()),
61 Err(_) => {
62 // Fail-secure: reject on database errors
63 AnnouncementResult::Reject(validation_err.to_string())
64 }
65 }
66 }
67 Err(_) => AnnouncementResult::Reject(validation_err.to_string()),
68 }
69 }
70 }
71 }
72
73 /// Create a bare git repository if it doesn't exist
74 /// Path format: <git_data_path>/<npub>/<identifier>.git
75 pub fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> {
76 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
77
78 // Check if repository already exists
79 if repo_path.exists() {
80 tracing::debug!("Repository already exists at {}", repo_path.display());
81 return Ok(());
82 }
83
84 // Create parent directory (npub directory)
85 let parent = repo_path
86 .parent()
87 .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?;
88
89 std::fs::create_dir_all(parent)
90 .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
91
92 // Initialize bare repository using git command
93 let output = std::process::Command::new("git")
94 .args(["init", "--bare", repo_path.to_str().unwrap()])
95 .output()
96 .map_err(|e| format!("Failed to execute git init: {}", e))?;
97
98 if !output.status.success() {
99 let stderr = String::from_utf8_lossy(&output.stderr);
100 return Err(format!("git init failed: {}", stderr));
101 }
102
103 tracing::info!("Created bare repository at {}", repo_path.display());
104 Ok(())
105 }
106
107 /// Check if a pubkey is listed as a maintainer in any announcement for this identifier
108 ///
109 /// A pubkey is considered a maintainer if:
110 /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR
111 /// 2. They are listed in the maintainers tag of ANY announcement with this identifier
112 ///
113 /// This enables accepting announcements from maintainers even when they don't list
114 /// this GRASP server, for maintainer chain discovery and GRASP-02 sync.
115 async fn is_maintainer_in_any_announcement(
116 &self,
117 identifier: &str,
118 author: &PublicKey,
119 ) -> Result<bool, String> {
120 // Query all announcements with this identifier that are already in the database
121 let filter = Filter::new()
122 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
123 .custom_tag(
124 SingleLetterTag::lowercase(Alphabet::D),
125 identifier.to_string(),
126 );
127
128 let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
129 Ok(events) => events.into_iter().collect(),
130 Err(e) => return Err(format!("Database query failed: {}", e)),
131 };
132
133 if announcements.is_empty() {
134 // No existing announcements for this identifier - author cannot be a maintainer
135 return Ok(false);
136 }
137
138 let author_hex = author.to_hex();
139
140 // Check each announcement to see if author is listed as a maintainer
141 for event in &announcements {
142 // Check if author is the owner of this announcement
143 if event.pubkey == *author {
144 return Ok(true);
145 }
146
147 // Check if author is listed in the maintainers tag
148 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
149 if announcement.maintainers.contains(&author_hex) {
150 return Ok(true);
151 }
152 }
153 }
154
155 Ok(false)
156 }
157} \ No newline at end of file