diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:42:00 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:42:00 +0000 |
| commit | 819866330c7e2f535a155d1d7efaf2e12dc15dc2 (patch) | |
| tree | d84c8361811544aad9cad089c0358b9028c8fb80 /src/nostr/policy/related.rs | |
| parent | fd0c87c787d0626b3546fa571541c9c809711821 (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/related.rs')
| -rw-r--r-- | src/nostr/policy/related.rs | 276 |
1 files changed, 276 insertions, 0 deletions
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). | ||
| 6 | use nostr_relay_builder::prelude::{ | ||
| 7 | Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag, | ||
| 8 | }; | ||
| 9 | |||
| 10 | use super::PolicyContext; | ||
| 11 | |||
| 12 | /// Result of reference checking | ||
| 13 | #[derive(Debug)] | ||
| 14 | pub 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)] | ||
| 27 | pub struct RelatedEventPolicy { | ||
| 28 | ctx: PolicyContext, | ||
| 29 | } | ||
| 30 | |||
| 31 | impl 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 | ||