upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/nostr/policy
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
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')
-rw-r--r--src/nostr/policy/announcement.rs157
-rw-r--r--src/nostr/policy/mod.rs41
-rw-r--r--src/nostr/policy/pr_event.rs198
-rw-r--r--src/nostr/policy/related.rs276
-rw-r--r--src/nostr/policy/state.rs419
5 files changed, 1091 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
diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs
new file mode 100644
index 0000000..6d67394
--- /dev/null
+++ b/src/nostr/policy/mod.rs
@@ -0,0 +1,41 @@
1/// Policy module for NIP-34 write policies
2///
3/// This module splits the large Nip34WritePolicy into focused sub-policies:
4/// - `AnnouncementPolicy` - Repository announcement validation
5/// - `StatePolicy` - State event validation + ref alignment
6/// - `PrEventPolicy` - PR/PR Update validation
7/// - `RelatedEventPolicy` - Forward/backward reference checking
8
9mod announcement;
10mod pr_event;
11mod related;
12mod state;
13
14pub use announcement::{AnnouncementPolicy, AnnouncementResult};
15pub use pr_event::PrEventPolicy;
16pub use related::{ReferenceResult, RelatedEventPolicy};
17pub use state::{AlignmentResult, StatePolicy, StateResult};
18
19use super::SharedDatabase;
20
21/// Shared context for all sub-policies
22#[derive(Clone)]
23pub struct PolicyContext {
24 pub domain: String,
25 pub database: SharedDatabase,
26 pub git_data_path: std::path::PathBuf,
27}
28
29impl PolicyContext {
30 pub fn new(
31 domain: impl Into<String>,
32 database: SharedDatabase,
33 git_data_path: impl Into<std::path::PathBuf>,
34 ) -> Self {
35 Self {
36 domain: domain.into(),
37 database,
38 git_data_path: git_data_path.into(),
39 }
40 }
41} \ No newline at end of file
diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs
new file mode 100644
index 0000000..fee9a2a
--- /dev/null
+++ b/src/nostr/policy/pr_event.rs
@@ -0,0 +1,198 @@
1/// PR Event Policy - PR/PR Update validation
2///
3/// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619)
4/// according to GRASP-01 specification.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::git;
9use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT};
10
11/// Policy for validating PR and PR Update events
12#[derive(Clone)]
13pub struct PrEventPolicy {
14 ctx: PolicyContext,
15}
16
17impl PrEventPolicy {
18 pub fn new(ctx: PolicyContext) -> Self {
19 Self { ctx }
20 }
21
22 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag
23 ///
24 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received,
25 /// this checks if a corresponding refs/nostr/<event-id> ref exists in the
26 /// repository and validates that it points to the correct commit (from the
27 /// `c` tag). If the ref exists but points to a different commit, the ref is
28 /// deleted.
29 ///
30 /// PR and PR Update events can have multiple `a` tags to update multiple
31 /// repositories simultaneously.
32 ///
33 /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
34 /// with their corresponding events.
35 ///
36 /// # Returns
37 /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
38 pub async fn validate_nostr_ref(&self, event: &Event) -> Result<Option<usize>, String> {
39 let event_id = event.id.to_hex();
40
41 // Extract the `c` tag (commit hash) from the PR event
42 let expected_commit = event.tags.iter().find_map(|tag| {
43 let tag_vec = tag.clone().to_vec();
44 if tag_vec.len() >= 2 && tag_vec[0] == "c" {
45 Some(tag_vec[1].clone())
46 } else {
47 None
48 }
49 });
50
51 let expected_commit = match expected_commit {
52 Some(c) => c,
53 None => {
54 tracing::debug!(
55 "PR event {} has no 'c' tag, skipping ref validation",
56 event_id
57 );
58 return Ok(None);
59 }
60 };
61
62 // Extract ALL `a` tags (repository references) from the PR event
63 // PR events can reference multiple repositories
64 // Format: 30617:<pubkey>:<identifier>
65 let repo_refs: Vec<String> = event
66 .tags
67 .iter()
68 .filter_map(|tag| {
69 let tag_vec = tag.clone().to_vec();
70 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
71 Some(tag_vec[1].clone())
72 } else {
73 None
74 }
75 })
76 .collect();
77
78 if repo_refs.is_empty() {
79 tracing::debug!(
80 "PR event {} has no repo 'a' tags, skipping ref validation",
81 event_id
82 );
83 return Ok(None);
84 }
85
86 let mut deleted_count = 0;
87
88 // Process each repository reference
89 for repo_ref in repo_refs {
90 // Parse the repo reference: 30617:<pubkey>:<identifier>
91 let parts: Vec<&str> = repo_ref.split(':').collect();
92 if parts.len() < 3 {
93 tracing::debug!(
94 "PR event {} has invalid 'a' tag format: {}",
95 event_id,
96 repo_ref
97 );
98 continue;
99 }
100
101 let repo_pubkey = match PublicKey::from_hex(parts[1]) {
102 Ok(pk) => pk,
103 Err(_) => {
104 tracing::debug!(
105 "PR event {} has invalid pubkey in 'a' tag: {}",
106 event_id,
107 parts[1]
108 );
109 continue;
110 }
111 };
112 let identifier = parts[2];
113
114 // Look up repository announcement to get the npub for path
115 let filter = Filter::new()
116 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
117 .author(repo_pubkey)
118 .custom_tag(
119 SingleLetterTag::lowercase(Alphabet::D),
120 identifier.to_string(),
121 );
122
123 let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
124 Ok(events) => events.into_iter().collect(),
125 Err(e) => {
126 tracing::warn!(
127 "Failed to query for repository announcement for PR {}: {}",
128 event_id,
129 e
130 );
131 continue;
132 }
133 };
134
135 if announcements.is_empty() {
136 tracing::debug!(
137 "No repository announcement found for PR event {} (repo {}:{})",
138 event_id,
139 repo_pubkey.to_hex(),
140 identifier
141 );
142 continue;
143 }
144
145 // Process each matching announcement (there could be multiple)
146 for announcement_event in announcements {
147 let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
148 Ok(a) => a,
149 Err(e) => {
150 tracing::warn!(
151 "Failed to parse announcement for PR {} validation: {}",
152 event_id,
153 e
154 );
155 continue;
156 }
157 };
158
159 // Build repository path
160 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
161
162 // Validate the ref
163 match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) {
164 Ok(true) => {
165 tracing::info!(
166 "Deleted mismatched refs/nostr/{} in {} (expected commit {})",
167 event_id,
168 repo_path.display(),
169 expected_commit
170 );
171 deleted_count += 1;
172 }
173 Ok(false) => {
174 tracing::debug!(
175 "refs/nostr/{} in {} is valid or doesn't exist",
176 event_id,
177 repo_path.display()
178 );
179 }
180 Err(e) => {
181 tracing::warn!(
182 "Failed to validate refs/nostr/{} in {}: {}",
183 event_id,
184 repo_path.display(),
185 e
186 );
187 }
188 }
189 }
190 }
191
192 if deleted_count > 0 {
193 Ok(Some(deleted_count))
194 } else {
195 Ok(None)
196 }
197 }
198} \ No newline at end of file
diff --git a/src/nostr/policy/related.rs b/src/nostr/policy/related.rs
new file mode 100644
index 0000000..1937ca7
--- /dev/null
+++ b/src/nostr/policy/related.rs
@@ -0,0 +1,276 @@
1/// Related Event Policy - Forward/backward reference checking
2///
3/// Handles validation of events that reference accepted repositories or events
4/// (backward references) and events that are referenced by accepted events
5/// (forward references).
6use nostr_relay_builder::prelude::{
7 Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag,
8};
9
10use super::PolicyContext;
11
12/// Result of reference checking
13#[derive(Debug)]
14pub enum ReferenceResult {
15 /// Event references an accepted repository (addressable ref found)
16 ReferencesRepository(String),
17 /// Event references an accepted event (event ID found)
18 ReferencesEvent(EventId),
19 /// Event is referenced by an accepted event (forward reference)
20 ReferencedByAccepted,
21 /// No valid references found - event is an orphan
22 Orphan,
23}
24
25/// Policy for checking event references (backward and forward)
26#[derive(Clone)]
27pub struct RelatedEventPolicy {
28 ctx: PolicyContext,
29}
30
31impl RelatedEventPolicy {
32 pub fn new(ctx: PolicyContext) -> Self {
33 Self { ctx }
34 }
35
36 /// Check all reference types for an event
37 ///
38 /// Returns the first valid reference found, or `Orphan` if none found.
39 pub async fn check_references(&self, event: &Event) -> Result<ReferenceResult, String> {
40 // Extract all reference tags from event
41 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
42
43 // Check 1: Does this event reference an accepted repository?
44 if let Some(addr_ref) = self.find_accepted_repository(&addressable_refs).await? {
45 return Ok(ReferenceResult::ReferencesRepository(addr_ref));
46 }
47
48 // Check 2: Does this event reference an accepted event?
49 if let Some(event_ref) = self.find_accepted_event(&event_refs).await? {
50 return Ok(ReferenceResult::ReferencesEvent(event_ref));
51 }
52
53 // Check 3: Is this event referenced by an accepted event?
54 if self.is_referenced_by_accepted(event).await? {
55 return Ok(ReferenceResult::ReferencedByAccepted);
56 }
57
58 // No valid references found
59 Ok(ReferenceResult::Orphan)
60 }
61
62 /// Extract all reference tags from an event (a, A, q, e, E)
63 /// Returns (addressable_refs, event_refs)
64 pub fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
65 let mut addressable_refs = Vec::new();
66 let mut event_refs = Vec::new();
67
68 for tag in event.tags.iter() {
69 let tag_vec = tag.clone().to_vec();
70 if tag_vec.is_empty() {
71 continue;
72 }
73
74 match tag_vec[0].as_str() {
75 // Addressable event references (a, A, q with kind:pubkey:identifier format)
76 "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => {
77 addressable_refs.push(tag_vec[1].clone());
78 }
79 // Event ID references (e, E, q with event ID format)
80 "e" | "E" if tag_vec.len() > 1 => {
81 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
82 event_refs.push(event_id);
83 }
84 }
85 "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => {
86 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
87 event_refs.push(event_id);
88 }
89 }
90 _ => {}
91 }
92 }
93
94 (addressable_refs, event_refs)
95 }
96
97 /// Check if any addressable events (repositories) exist in database
98 /// Returns the first matching addressable reference found, or None if none match
99 async fn find_accepted_repository(
100 &self,
101 addressables: &[String],
102 ) -> Result<Option<String>, String> {
103 if addressables.is_empty() {
104 return Ok(None);
105 }
106
107 // Parse all addressable references
108 let mut parsed_refs = Vec::new();
109 for addr in addressables {
110 let parts: Vec<&str> = addr.split(':').collect();
111 if parts.len() < 3 {
112 continue; // Skip invalid format
113 }
114
115 let kind = match parts[0].parse::<u16>() {
116 Ok(k) => k,
117 Err(_) => continue, // Skip invalid kind
118 };
119 let pubkey = match PublicKey::from_hex(parts[1]) {
120 Ok(pk) => pk,
121 Err(_) => continue, // Skip invalid pubkey
122 };
123 let identifier = parts[2].to_string();
124
125 parsed_refs.push((addr.clone(), kind, pubkey, identifier));
126 }
127
128 if parsed_refs.is_empty() {
129 return Ok(None);
130 }
131
132 // Group by kind to reduce queries
133 use std::collections::HashMap;
134 let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new();
135 for (addr, kind, pubkey, identifier) in parsed_refs {
136 by_kind
137 .entry(kind)
138 .or_default()
139 .push((addr, pubkey, identifier));
140 }
141
142 // Query each kind group
143 for (kind, refs) in by_kind {
144 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
145
146 let filter = Filter::new().kind(Kind::from(kind)).authors(authors);
147
148 match self.ctx.database.query(filter).await {
149 Ok(events) => {
150 // Check if any event matches our identifier requirements
151 for event in events {
152 for (addr, _pubkey, identifier) in &refs {
153 // Match identifier tag
154 if event.tags.iter().any(|tag| {
155 let tag_vec = tag.clone().to_vec();
156 tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier
157 }) {
158 return Ok(Some(addr.clone()));
159 }
160 }
161 }
162 }
163 Err(e) => return Err(format!("Database query failed: {}", e)),
164 }
165 }
166
167 Ok(None)
168 }
169
170 /// Check if any events exist in database
171 /// Returns the first matching event ID found, or None if none match
172 async fn find_accepted_event(
173 &self,
174 event_ids: &[EventId],
175 ) -> Result<Option<EventId>, String> {
176 if event_ids.is_empty() {
177 return Ok(None);
178 }
179
180 // Single query for all event IDs
181 let filter = Filter::new().ids(event_ids.iter().copied());
182
183 match self.ctx.database.query(filter).await {
184 Ok(events) => {
185 // Get first event from the iterator
186 Ok(events.into_iter().next().map(|e| e.id))
187 }
188 Err(e) => Err(format!("Database query failed: {}", e)),
189 }
190 }
191
192 /// Check if any accepted event references this event (forward reference)
193 ///
194 /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format
195 /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format
196 /// For regular events: Only checks event ID reference tags (e, E, q)
197 async fn is_referenced_by_accepted(&self, event: &Event) -> Result<bool, String> {
198 let kind_u16 = event.kind.as_u16();
199
200 // Check if this is any kind of replaceable event
201 let is_regular_replaceable = (10000..20000).contains(&kind_u16);
202 let is_parameterized_replaceable = (30000..40000).contains(&kind_u16);
203
204 if is_regular_replaceable || is_parameterized_replaceable {
205 // Build the appropriate address format based on event type
206 let address = if is_parameterized_replaceable {
207 // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons)
208 let identifier = event
209 .tags
210 .iter()
211 .find_map(|tag| {
212 let tag_vec = tag.clone().to_vec();
213 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
214 Some(tag_vec[1].clone())
215 } else {
216 None
217 }
218 })
219 .unwrap_or_default(); // Empty string if no 'd' tag
220 format!(
221 "{}:{}:{}",
222 event.kind.as_u16(),
223 event.pubkey.to_hex(),
224 identifier
225 )
226 } else {
227 // For regular replaceable: kind:pubkey format (1 colon)
228 format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex())
229 };
230
231 // Check addressable reference tags: a, A, q (with address format)
232 let addressable_tags = [
233 SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference
234 SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference
235 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID)
236 ];
237
238 for tag_type in &addressable_tags {
239 let filter = Filter::new().custom_tag(*tag_type, address.clone());
240
241 match self.ctx.database.query(filter).await {
242 Ok(events) => {
243 if !events.is_empty() {
244 return Ok(true);
245 }
246 }
247 Err(e) => return Err(format!("Database query failed: {}", e)),
248 }
249 }
250 } else {
251 // For regular events, check event ID reference tags: e, E, q (with hex ID)
252 let event_id_hex = event.id.to_hex();
253
254 let event_id_tags = [
255 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference
256 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference
257 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference
258 ];
259
260 for tag_type in &event_id_tags {
261 let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone());
262
263 match self.ctx.database.query(filter).await {
264 Ok(events) => {
265 if !events.is_empty() {
266 return Ok(true);
267 }
268 }
269 Err(e) => return Err(format!("Database query failed: {}", e)),
270 }
271 }
272 }
273
274 Ok(false)
275 }
276} \ No newline at end of file
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
new file mode 100644
index 0000000..5692bd8
--- /dev/null
+++ b/src/nostr/policy/state.rs
@@ -0,0 +1,419 @@
1/// State Policy - State event validation + ref alignment
2///
3/// Handles validation of NIP-34 repository state events (kind 30618)
4/// and aligns git refs with authorized state according to GRASP-01.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::git;
9use crate::nostr::events::{
10 validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT,
11 KIND_REPOSITORY_STATE,
12};
13
14/// Result of aligning a repository with authorized state
15#[derive(Debug, Default)]
16pub struct AlignmentResult {
17 /// Number of refs created
18 pub refs_created: usize,
19 /// Number of refs updated
20 pub refs_updated: usize,
21 /// Number of refs deleted
22 pub refs_deleted: usize,
23 /// Whether HEAD was set
24 pub head_set: bool,
25}
26
27impl AlignmentResult {
28 pub fn has_changes(&self) -> bool {
29 self.refs_created > 0 || self.refs_updated > 0 || self.refs_deleted > 0 || self.head_set
30 }
31}
32
33/// Result of state policy evaluation
34#[derive(Debug)]
35pub enum StateResult {
36 /// Accept: Event passes validation
37 Accept,
38 /// Reject: Event fails validation with reason
39 Reject(String),
40}
41
42/// Policy for validating repository state events and aligning refs
43#[derive(Clone)]
44pub struct StatePolicy {
45 ctx: PolicyContext,
46}
47
48impl StatePolicy {
49 pub fn new(ctx: PolicyContext) -> Self {
50 Self { ctx }
51 }
52
53 /// Validate a repository state event
54 pub fn validate(&self, event: &Event) -> StateResult {
55 match validate_state(event) {
56 Ok(_) => StateResult::Accept,
57 Err(e) => StateResult::Reject(e.to_string()),
58 }
59 }
60
61 /// Process a state event: validate and align owner repositories
62 ///
63 /// Returns the number of repositories aligned if successful.
64 pub async fn process_state_event(&self, event: &Event) -> Result<usize, String> {
65 // Parse state to get HEAD and branch info
66 let state = RepositoryState::from_event(event.clone())
67 .map_err(|e| format!("Failed to parse state: {}", e))?;
68
69 // Identify owner repositories for which this is the latest authorized state
70 let owner_repos = self.identify_owner_repositories(&state).await?;
71 let repo_count = owner_repos.len();
72 let mut total_aligned = 0;
73
74 // Align each owner repository with the authorized state
75 for (_announcement, repo_path) in owner_repos {
76 let result = self.align_repository_with_state(&repo_path, &state);
77
78 if result.has_changes() {
79 tracing::info!(
80 "Aligned {} with state: created={}, updated={}, deleted={}, head_set={}",
81 repo_path.display(),
82 result.refs_created,
83 result.refs_updated,
84 result.refs_deleted,
85 result.head_set
86 );
87 total_aligned += 1;
88 }
89 }
90
91 if repo_count > 0 {
92 tracing::info!(
93 "Processed state event for {} repo(s) ({} aligned) with identifier {}",
94 repo_count,
95 total_aligned,
96 state.identifier
97 );
98 } else {
99 tracing::debug!(
100 "No owner repos to align for state - git data not available yet or not latest"
101 );
102 }
103
104 Ok(total_aligned)
105 }
106
107 /// Check if this state event is the latest for its identifier among authorized authors
108 ///
109 /// A state is considered "latest" if no other state event in the database
110 /// from an authorized author has a newer timestamp.
111 async fn is_latest_state_for_identifier(
112 &self,
113 state: &RepositoryState,
114 authorized_pubkeys: &[PublicKey],
115 ) -> Result<bool, String> {
116 let filter = Filter::new()
117 .kind(Kind::from(KIND_REPOSITORY_STATE))
118 .custom_tag(
119 SingleLetterTag::lowercase(Alphabet::D),
120 state.identifier.clone(),
121 );
122
123 match self.ctx.database.query(filter).await {
124 Ok(events) => {
125 for event in events {
126 // Skip comparing to self (same event ID)
127 if event.id == state.event.id {
128 continue;
129 }
130 // Only consider events from authorized authors for this announcement
131 if !authorized_pubkeys.contains(&event.pubkey) {
132 continue;
133 }
134 // If any existing event from an authorized author is newer, this is not the latest
135 if event.created_at > state.event.created_at {
136 tracing::debug!(
137 "State {} is not latest: found newer state {} from {} (ts {} > {})",
138 state.event.id.to_hex(),
139 event.id.to_hex(),
140 event.pubkey.to_hex(),
141 event.created_at.as_secs(),
142 state.event.created_at.as_secs()
143 );
144 return Ok(false);
145 }
146 }
147 Ok(true)
148 }
149 Err(e) => Err(format!("Database query failed: {}", e)),
150 }
151 }
152
153 /// Find all repository announcements where the given pubkey is authorized
154 async fn find_authorized_announcements(
155 &self,
156 identifier: &str,
157 state_author: &PublicKey,
158 ) -> Result<Vec<RepositoryAnnouncement>, String> {
159 let filter = Filter::new()
160 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
161 .custom_tag(
162 SingleLetterTag::lowercase(Alphabet::D),
163 identifier.to_string(),
164 );
165
166 match self.ctx.database.query(filter).await {
167 Ok(events) => {
168 let mut authorized = Vec::new();
169 let state_author_hex = state_author.to_hex();
170
171 for event in events {
172 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
173 // Check if state author is authorized for this announcement
174 let is_owner = event.pubkey == *state_author;
175 let is_maintainer = announcement.maintainers.contains(&state_author_hex);
176
177 if is_owner || is_maintainer {
178 tracing::debug!(
179 "Found authorized announcement for {}: owner={}, maintainer={}",
180 identifier,
181 if is_owner {
182 event.pubkey.to_hex()
183 } else {
184 "n/a".to_string()
185 },
186 is_maintainer
187 );
188 authorized.push(announcement);
189 }
190 }
191 }
192 Ok(authorized)
193 }
194 Err(e) => Err(format!("Database query failed: {}", e)),
195 }
196 }
197
198 /// Identify all owner repositories for which this state event is the latest authorized state
199 async fn identify_owner_repositories(
200 &self,
201 state: &RepositoryState,
202 ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> {
203 // Find all announcements where state author is authorized
204 let announcements = self
205 .find_authorized_announcements(&state.identifier, &state.event.pubkey)
206 .await?;
207
208 if announcements.is_empty() {
209 tracing::debug!(
210 "No authorized announcements found for state {} by {}",
211 state.identifier,
212 state.event.pubkey.to_hex()
213 );
214 return Ok(Vec::new());
215 }
216
217 let mut owner_repos = Vec::new();
218
219 for announcement in announcements {
220 // Build the list of authorized pubkeys for this specific announcement
221 let mut authorized_pubkeys = vec![announcement.event.pubkey];
222 for maintainer_hex in &announcement.maintainers {
223 if let Ok(pk) = PublicKey::from_hex(maintainer_hex) {
224 authorized_pubkeys.push(pk);
225 }
226 }
227
228 // Check if this is the latest state event for THIS announcement's context
229 if !self
230 .is_latest_state_for_identifier(state, &authorized_pubkeys)
231 .await?
232 {
233 tracing::debug!(
234 "Skipping {} in {}'s repo - not the latest state event for this context",
235 state.identifier,
236 announcement.event.pubkey.to_hex()
237 );
238 continue;
239 }
240
241 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
242 let repo_path = self.ctx.git_data_path.join(announcement.repo_path().clone());
243 owner_repos.push((announcement, repo_path));
244 }
245
246 Ok(owner_repos)
247 }
248
249 /// Align a repository's refs with the authorized state
250 ///
251 /// This function:
252 /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/)
253 /// 2. Updates refs that exist in state if we have the commit
254 /// 3. Sets HEAD if the HEAD branch's commit is available
255 pub fn align_repository_with_state(
256 &self,
257 repo_path: &std::path::Path,
258 state: &RepositoryState,
259 ) -> AlignmentResult {
260 let mut result = AlignmentResult::default();
261
262 // Check if repository exists
263 if !repo_path.exists() {
264 tracing::debug!(
265 "Repository not found at {}, cannot align with state",
266 repo_path.display()
267 );
268 return result;
269 }
270
271 // Get current refs from the repository
272 let current_refs = match git::list_refs(repo_path) {
273 Ok(refs) => refs,
274 Err(e) => {
275 tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e);
276 return result;
277 }
278 };
279
280 // Build expected refs from state
281 let mut expected_refs: std::collections::HashMap<String, String> =
282 std::collections::HashMap::new();
283
284 for branch in &state.branches {
285 let ref_name = format!("refs/heads/{}", branch.name);
286 expected_refs.insert(ref_name, branch.commit.clone());
287 }
288
289 for tag in &state.tags {
290 let ref_name = format!("refs/tags/{}", tag.name);
291 expected_refs.insert(ref_name, tag.commit.clone());
292 }
293
294 // Process current refs: update or delete as needed
295 for (ref_name, current_commit) in &current_refs {
296 // Only process refs/heads/ and refs/tags/
297 if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
298 continue;
299 }
300
301 match expected_refs.get(ref_name) {
302 Some(expected_commit) => {
303 // Ref should exist - check if commit matches
304 if current_commit != expected_commit {
305 // Check if we have the expected commit
306 if git::commit_exists(repo_path, expected_commit) {
307 // Update the ref
308 match git::update_ref(repo_path, ref_name, expected_commit) {
309 Ok(()) => {
310 tracing::info!(
311 "Updated {} from {} to {} in {}",
312 ref_name,
313 current_commit,
314 expected_commit,
315 repo_path.display()
316 );
317 result.refs_updated += 1;
318 }
319 Err(e) => {
320 tracing::warn!(
321 "Failed to update {} in {}: {}",
322 ref_name,
323 repo_path.display(),
324 e
325 );
326 }
327 }
328 } else {
329 tracing::debug!(
330 "Commit {} not available for {} in {}",
331 expected_commit,
332 ref_name,
333 repo_path.display()
334 );
335 }
336 }
337 }
338 None => {
339 // Ref should not exist - delete it
340 match git::delete_ref(repo_path, ref_name) {
341 Ok(()) => {
342 tracing::info!(
343 "Deleted {} (not in state) from {}",
344 ref_name,
345 repo_path.display()
346 );
347 result.refs_deleted += 1;
348 }
349 Err(e) => {
350 tracing::warn!(
351 "Failed to delete {} from {}: {}",
352 ref_name,
353 repo_path.display(),
354 e
355 );
356 }
357 }
358 }
359 }
360 }
361
362 // Add refs that exist in state but not in repo (if we have the commit)
363 for (ref_name, expected_commit) in &expected_refs {
364 let exists = current_refs.iter().any(|(r, _)| r == ref_name);
365 if !exists && git::commit_exists(repo_path, expected_commit) {
366 match git::update_ref(repo_path, ref_name, expected_commit) {
367 Ok(()) => {
368 tracing::info!(
369 "Created {} at {} in {}",
370 ref_name,
371 expected_commit,
372 repo_path.display()
373 );
374 result.refs_created += 1;
375 }
376 Err(e) => {
377 tracing::warn!(
378 "Failed to create {} in {}: {}",
379 ref_name,
380 repo_path.display(),
381 e
382 );
383 }
384 }
385 }
386 }
387
388 // Set HEAD if specified in state
389 if let Some(head_ref) = &state.head {
390 if let Some(branch_name) = state.get_head_branch() {
391 if let Some(head_commit) = state.get_branch_commit(branch_name) {
392 match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
393 Ok(true) => {
394 tracing::info!(
395 "Set HEAD to {} in {} (from state by {})",
396 head_ref,
397 repo_path.display(),
398 state.event.pubkey.to_hex()
399 );
400 result.head_set = true;
401 }
402 Ok(false) => {
403 tracing::debug!(
404 "HEAD commit {} not available yet in {}",
405 head_commit,
406 repo_path.display()
407 );
408 }
409 Err(e) => {
410 tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
411 }
412 }
413 }
414 }
415 }
416
417 result
418 }
419} \ No newline at end of file