upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-20 23:44:05 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-21 03:05:41 +0000
commit2bbb7292c978d36464b6166faa78223677389ef6 (patch)
treeef82eb6f9267c27a08e758d32112a33c13991717 /src
parent519fdc66930280cd1772417dca327ed858333d64 (diff)
Implement GRASP-01 stateful write policy with database queries
- Add Nip34WritePolicy with Arc<MemoryDatabase> for stateful event validation - Implement full GRASP-01 event acceptance policy: * Accept events referencing accepted repositories (via a, A, q tags) * Accept events referencing accepted events (transitive, via e, E, q tags) * Support forward references (events referenced by accepted events) * Reject orphan events with no valid references - Extract and validate all reference tag types (a, A, q, e, E) - Query database for repository and event existence checks - Implement fail-secure error handling for database query failures Test improvements: - Fix send_and_verify_rejected to handle relay rejection errors properly - Fix RepoWithIssue fixture usage in forward reference tests - Add database synchronization polling for race condition mitigation - Achieve 94% test pass rate (16/17 integration tests passing)
Diffstat (limited to 'src')
-rw-r--r--src/nostr/builder.rs270
1 files changed, 237 insertions, 33 deletions
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index cd1f4d2..7a35bb7 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -4,8 +4,11 @@
4/// preserved from the original implementation. 4/// preserved from the original implementation.
5use std::net::SocketAddr; 5use std::net::SocketAddr;
6use std::path::Path; 6use std::path::Path;
7use std::sync::Arc;
7 8
8use nostr::nips::nip19::ToBech32; 9use nostr::nips::nip19::ToBech32;
10use nostr::prelude::{Alphabet, SingleLetterTag};
11use nostr::{EventId, Filter, Kind, PublicKey};
9use nostr_relay_builder::prelude::*; 12use nostr_relay_builder::prelude::*;
10 13
11use crate::config::Config; 14use crate::config::Config;
@@ -13,20 +16,141 @@ use crate::nostr::events::{
13 validate_announcement, validate_state, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, 16 validate_announcement, validate_state, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE,
14}; 17};
15 18
16/// NIP-34 Write Policy 19/// NIP-34 Write Policy with Full GRASP-01 Event Validation
17/// 20///
18/// Validates repository announcement and state events according to GRASP-01 spec. 21/// Validates all events according to GRASP-01 specification:
19/// Preserves all original validation logic from src/nostr/events.rs. 22/// - Repository announcements must list service in clone and relays tags
23/// - Repository state announcements must have valid structure
24/// - Other events must reference accepted repositories or events
25/// - Forward references are supported (events referenced by accepted events)
26/// - Orphan events with no valid references are rejected
27///
28/// Uses stateful database queries to check event relationships.
20#[derive(Debug, Clone)] 29#[derive(Debug, Clone)]
21pub struct Nip34WritePolicy { 30pub struct Nip34WritePolicy {
22 domain: String, 31 domain: String,
32 database: Arc<MemoryDatabase>,
23} 33}
24 34
25impl Nip34WritePolicy { 35impl Nip34WritePolicy {
26 pub fn new(domain: impl Into<String>) -> Self { 36 pub fn new(domain: impl Into<String>, database: Arc<MemoryDatabase>) -> Self {
27 Self { 37 Self {
28 domain: domain.into(), 38 domain: domain.into(),
39 database,
40 }
41 }
42
43 /// Extract all reference tags from an event (a, A, q, e, E)
44 /// Returns (addressable_refs, event_refs)
45 fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
46 let mut addressable_refs = Vec::new();
47 let mut event_refs = Vec::new();
48
49 for tag in event.tags.iter() {
50 let tag_vec = tag.clone().to_vec();
51 if tag_vec.is_empty() {
52 continue;
53 }
54
55 match tag_vec[0].as_str() {
56 // Addressable event references (a, A, q with kind:pubkey:identifier format)
57 "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => {
58 addressable_refs.push(tag_vec[1].clone());
59 }
60 // Event ID references (e, E, q with event ID format)
61 "e" | "E" if tag_vec.len() > 1 => {
62 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
63 event_refs.push(event_id);
64 }
65 }
66 "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => {
67 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
68 event_refs.push(event_id);
69 }
70 }
71 _ => {}
72 }
73 }
74
75 (addressable_refs, event_refs)
76 }
77
78 /// Check if an addressable event (repository) exists in database
79 async fn is_accepted_repository(
80 database: &Arc<MemoryDatabase>,
81 addressable: &str,
82 ) -> Result<bool, String> {
83 // Parse addressable format: kind:pubkey:identifier
84 let parts: Vec<&str> = addressable.split(':').collect();
85 if parts.len() < 3 {
86 return Ok(false);
87 }
88
89 let kind = parts[0]
90 .parse::<u16>()
91 .map_err(|e| format!("Invalid kind in addressable: {}", e))?;
92 let pubkey = PublicKey::from_hex(parts[1])
93 .map_err(|e| format!("Invalid pubkey in addressable: {}", e))?;
94 let identifier = parts[2];
95
96 // Query database for this addressable event
97 let filter = Filter::new()
98 .kind(Kind::from(kind))
99 .author(pubkey)
100 .identifier(identifier);
101
102 match database.query(filter).await {
103 Ok(events) => Ok(!events.is_empty()),
104 Err(e) => Err(format!("Database query failed: {}", e)),
105 }
106 }
107
108 /// Check if an event exists in database
109 async fn is_accepted_event(
110 database: &Arc<MemoryDatabase>,
111 event_id: &EventId,
112 ) -> Result<bool, String> {
113 let filter = Filter::new().id(*event_id);
114
115 match database.query(filter).await {
116 Ok(events) => Ok(!events.is_empty()),
117 Err(e) => Err(format!("Database query failed: {}", e)),
118 }
119 }
120
121 /// Check if any accepted event references this event ID (forward reference)
122 /// Checks all reference tag types: e, E, q, Q (event ID references only, not addressable)
123 ///
124 /// Note: Must check each tag type separately as custom_tag chaining creates AND not OR
125 async fn is_referenced_by_accepted(
126 database: &Arc<MemoryDatabase>,
127 event_id: &EventId,
128 ) -> Result<bool, String> {
129 let event_id_hex = event_id.to_hex();
130
131 // Check each tag type that can reference event IDs
132 // Note: 'a' and 'A' tags use addressable format (kind:pubkey:d), not event IDs
133 let tag_types = [
134 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference
135 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference
136 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference
137 SingleLetterTag::uppercase(Alphabet::Q), // 'Q' - uppercase quote (if used)
138 ];
139
140 for tag_type in &tag_types {
141 let filter = Filter::new().custom_tag(tag_type.clone(), event_id_hex.clone());
142
143 match database.query(filter).await {
144 Ok(events) => {
145 if !events.is_empty() {
146 return Ok(true);
147 }
148 }
149 Err(e) => return Err(format!("Database query failed: {}", e)),
150 }
29 } 151 }
152
153 Ok(false)
30 } 154 }
31} 155}
32 156
@@ -36,86 +160,166 @@ impl WritePolicy for Nip34WritePolicy {
36 event: &'a nostr_relay_builder::prelude::Event, 160 event: &'a nostr_relay_builder::prelude::Event,
37 _addr: &'a SocketAddr, 161 _addr: &'a SocketAddr,
38 ) -> BoxedFuture<'a, PolicyResult> { 162 ) -> BoxedFuture<'a, PolicyResult> {
163 let database = self.database.clone();
164 let domain = self.domain.clone();
165
39 Box::pin(async move { 166 Box::pin(async move {
167 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
168
40 match event.kind.as_u16() { 169 match event.kind.as_u16() {
41 KIND_REPOSITORY_ANNOUNCEMENT => match validate_announcement(event, &self.domain) { 170 KIND_REPOSITORY_ANNOUNCEMENT => match validate_announcement(event, &domain) {
42 Ok(_) => { 171 Ok(_) => {
43 tracing::debug!( 172 tracing::debug!(
44 "Accepted repository announcement: {}", 173 "Accepted repository announcement: {}",
45 event 174 event_id_str
46 .id
47 .to_bech32()
48 .unwrap_or_else(|_| "invalid".to_string())
49 ); 175 );
50 PolicyResult::Accept 176 PolicyResult::Accept
51 } 177 }
52 Err(e) => { 178 Err(e) => {
53 tracing::warn!( 179 tracing::warn!(
54 "Rejected repository announcement {}: {}", 180 "Rejected repository announcement {}: {}",
55 event 181 event_id_str,
56 .id
57 .to_bech32()
58 .unwrap_or_else(|_| "invalid".to_string()),
59 e 182 e
60 ); 183 );
61 PolicyResult::Reject(e.to_string()) 184 PolicyResult::Reject(e.to_string())
62 } 185 }
63 }, 186 },
64 KIND_REPOSITORY_STATE => match validate_state(event) { 187 KIND_REPOSITORY_STATE =>match validate_state(event) {
65 Ok(_) => { 188 Ok(_) => {
66 tracing::debug!( 189 tracing::debug!(
67 "Accepted repository state: {}", 190 "Accepted repository state: {}",
68 event 191 event_id_str
69 .id
70 .to_bech32()
71 .unwrap_or_else(|_| "invalid".to_string())
72 ); 192 );
73 PolicyResult::Accept 193 PolicyResult::Accept
74 } 194 }
75 Err(e) => { 195 Err(e) => {
76 tracing::warn!( 196 tracing::warn!(
77 "Rejected repository state {}: {}", 197 "Rejected repository state {}: {}",
78 event 198 event_id_str,
79 .id
80 .to_bech32()
81 .unwrap_or_else(|_| "invalid".to_string()),
82 e 199 e
83 ); 200 );
84 PolicyResult::Reject(e.to_string()) 201 PolicyResult::Reject(e.to_string())
85 } 202 }
86 }, 203 },
87 // Accept all other event kinds without validation 204 // GRASP-01: Check if event references accepted repositories or events
88 _ => PolicyResult::Accept, 205 _ => {
206 // Extract all reference tags from event
207 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
208
209 // Check 1: Does this event reference an accepted repository?
210 for addr_ref in &addressable_refs {
211 match Self::is_accepted_repository(&database, addr_ref).await {
212 Ok(true) => {
213 tracing::debug!(
214 "Accepted event {}: references accepted repository {}",
215 event_id_str,
216 addr_ref
217 );
218 return PolicyResult::Accept;
219 }
220 Ok(false) => {
221 // Continue checking other references
222 }
223 Err(e) => {
224 tracing::warn!(
225 "Database query failed for event {}, rejecting (fail-secure): {}",
226 event_id_str,
227 e
228 );
229 return PolicyResult::Reject(format!("Database query failed: {}", e));
230 }
231 }
232 }
233
234 // Check 2: Does this event reference an accepted event? (transitive)
235 for event_ref in &event_refs {
236 match Self::is_accepted_event(&database, event_ref).await {
237 Ok(true) => {
238 tracing::debug!(
239 "Accepted event {}: references accepted event {}",
240 event_id_str,
241 event_ref
242 );
243 return PolicyResult::Accept;
244 }
245 Ok(false) => {
246 // Continue checking other references
247 }
248 Err(e) => {
249 tracing::warn!(
250 "Database query failed for event {}, rejecting (fail-secure): {}",
251 event_id_str,
252 e
253 );
254 return PolicyResult::Reject(format!("Database query failed: {}", e));
255 }
256 }
257 }
258
259 // Check 3: Is this event referenced by an accepted event? (forward reference)
260 match Self::is_referenced_by_accepted(&database, &event.id).await {
261 Ok(true) => {
262 tracing::debug!(
263 "Accepted event {}: referenced by accepted event",
264 event_id_str
265 );
266 return PolicyResult::Accept;
267 }
268 Ok(false) => {
269 // No forward references found, continue to rejection
270 }
271 Err(e) => {
272 tracing::warn!(
273 "Database query failed for event {}, rejecting (fail-secure): {}",
274 event_id_str,
275 e
276 );
277 return PolicyResult::Reject(format!("Database query failed: {}", e));
278 }
279 }
280
281 // No valid references found - reject as orphan event
282 tracing::info!(
283 "Rejected orphan event {}: no references to accepted repos or events (checked {} addressable, {} event refs)",
284 event_id_str,
285 addressable_refs.len(),
286 event_refs.len()
287 );
288 PolicyResult::Reject(
289 "Event must reference an accepted repository or accepted event".to_string()
290 )
291 }
89 } 292 }
90 }) 293 })
91 } 294 }
92} 295}
93 296
94/// Create a configured LocalRelay with NIP-34 validation 297/// Create a configured LocalRelay with full GRASP-01 validation
95pub fn create_relay(config: &Config) -> Result<LocalRelay> { 298pub fn create_relay(config: &Config) -> Result<LocalRelay> {
96 tracing::info!("Configuring nostr relay..."); 299 tracing::info!("Configuring nostr relay with GRASP-01 validation...");
97 300
98 // Determine database path 301 // Determine database path
99 let db_path = Path::new(&config.relay_data_path); 302 let db_path = Path::new(&config.relay_data_path);
100 303
101 // Create database - using in-memory for now, can switch to persistent later 304 // Create database - using in-memory for now, can switch to persistent later
102 // TODO: Add configuration for NostrDB or LMDB backends 305 // TODO: Add configuration for NostrDB or LMDB backends
103 let database = MemoryDatabase::with_opts(MemoryDatabaseOptions { 306 let database = Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
104 events: true, 307 events: true,
105 max_events: Some(100_000), 308 max_events: Some(100_000),
106 }); 309 }));
107 310
108 tracing::info!("Using in-memory database (path: {})", db_path.display()); 311 tracing::info!("Using in-memory database (path: {})", db_path.display());
109 312
110 // Build relay with NIP-34 validation 313 // Build relay with GRASP-01 validation
314 // Clone Arc for the write policy so both relay and policy can access the database
111 let builder = RelayBuilder::default() 315 let builder = RelayBuilder::default()
112 .database(database) 316 .database(database.clone())
113 .write_policy(Nip34WritePolicy::new(&config.domain)); 317 .write_policy(Nip34WritePolicy::new(&config.domain, database.clone()));
114 318
115 tracing::info!( 319 tracing::info!(
116 "Relay configured with NIP-34 validation for domain: {}", 320 "Relay configured with GRASP-01 validation for domain: {}",
117 config.domain 321 config.domain
118 ); 322 );
119 323
120 Ok(LocalRelay::new(builder)) 324 Ok(LocalRelay::new(builder))
121} 325} \ No newline at end of file