upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/nostr/policy/related.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/related.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/related.rs')
-rw-r--r--src/nostr/policy/related.rs276
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).
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