upleb.uk

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

summaryrefslogtreecommitdiff
path: root/grasp-audit
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-11-21 04:44:40 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-11-21 04:44:40 +0000
commit7dda553918705277c7fa5b903c6a40e4b4a0aa8d (patch)
tree4f3511cd3fe56928bd2aa9a22f4ddd592f4c6b83 /grasp-audit
parent2e799fa7ec57d284c643df8b8dc54471470f5c59 (diff)
add nip11
Diffstat (limited to 'grasp-audit')
-rw-r--r--grasp-audit/Cargo.toml4
-rw-r--r--grasp-audit/src/client.rs19
-rw-r--r--grasp-audit/src/specs/grasp01/nip11_document.rs189
3 files changed, 178 insertions, 34 deletions
diff --git a/grasp-audit/Cargo.toml b/grasp-audit/Cargo.toml
index 278c855..0bc008a 100644
--- a/grasp-audit/Cargo.toml
+++ b/grasp-audit/Cargo.toml
@@ -33,6 +33,10 @@ clap = { version = "4", features = ["derive"] }
33uuid = { version = "1", features = ["v4"] } 33uuid = { version = "1", features = ["v4"] }
34chrono = "0.4" 34chrono = "0.4"
35 35
36# HTTP Client
37reqwest = { version = "0.11", features = ["json"] }
38regex = "1"
39
36# Logging 40# Logging
37tracing = "0.1" 41tracing = "0.1"
38tracing-subscriber = { version = "0.3", features = ["env-filter"] } 42tracing-subscriber = { version = "0.3", features = ["env-filter"] }
diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs
index 019f4cb..35aaccd 100644
--- a/grasp-audit/src/client.rs
+++ b/grasp-audit/src/client.rs
@@ -84,6 +84,25 @@ impl AuditClient {
84 self.keys.public_key() 84 self.keys.public_key()
85 } 85 }
86 86
87 /// Get the relay URL
88 pub async fn relay_url(&self) -> Result<String> {
89 let relays = self.client.relays().await;
90 let relay = relays.values().next()
91 .ok_or_else(|| anyhow!("No relays configured"))?;
92 Ok(relay.url().to_string())
93 }
94
95 /// Convert WebSocket URL to HTTP(S) URL for NIP-11 requests
96 pub fn ws_to_http_url(ws_url: &str) -> Result<String> {
97 if ws_url.starts_with("ws://") {
98 Ok(ws_url.replace("ws://", "http://"))
99 } else if ws_url.starts_with("wss://") {
100 Ok(ws_url.replace("wss://", "https://"))
101 } else {
102 Err(anyhow!("Invalid WebSocket URL: {}", ws_url))
103 }
104 }
105
87 /// Check if connected to relay 106 /// Check if connected to relay
88 pub async fn is_connected(&self) -> bool { 107 pub async fn is_connected(&self) -> bool {
89 // Check if we have any connected relays 108 // Check if we have any connected relays
diff --git a/grasp-audit/src/specs/grasp01/nip11_document.rs b/grasp-audit/src/specs/grasp01/nip11_document.rs
index be04777..bb864f2 100644
--- a/grasp-audit/src/specs/grasp01/nip11_document.rs
+++ b/grasp-audit/src/specs/grasp01/nip11_document.rs
@@ -34,25 +34,53 @@ impl Nip11DocumentTests {
34 /// 34 ///
35 /// Spec: Line 11 of ../grasp/01.md 35 /// Spec: Line 11 of ../grasp/01.md
36 /// Requirement: MUST serve NIP-11 document 36 /// Requirement: MUST serve NIP-11 document
37 async fn test_nip11_document_exists(_client: &AuditClient) -> TestResult { 37 pub async fn test_nip11_document_exists(client: &AuditClient) -> TestResult {
38 TestResult::new( 38 TestResult::new(
39 "nip11_document_exists", 39 "nip11_document_exists",
40 "GRASP-01:nostr-relay:11", 40 "GRASP-01:nostr-relay:11",
41 "Serve NIP-11 relay information document", 41 "Serve NIP-11 relay information document",
42 ) 42 )
43 .run(|| async { 43 .run(|| async {
44 // TODO: Implementation
45 // 1. Extract HTTP(S) URL from client's WebSocket URL 44 // 1. Extract HTTP(S) URL from client's WebSocket URL
46 // - ws://localhost:8081 -> http://localhost:8081 45 let ws_url = client.relay_url().await
47 // - wss://relay.example.com -> https://relay.example.com 46 .map_err(|e| format!("Failed to get relay URL: {}", e))?;
48 // 2. HTTP GET to base URL with header: 47 let http_url = AuditClient::ws_to_http_url(&ws_url)
49 // - Accept: application/nostr+json 48 .map_err(|e| format!("Failed to convert WebSocket URL to HTTP: {}", e))?;
49
50 // 2. HTTP GET to base URL with Accept: application/nostr+json header
51 let http_client = reqwest::Client::new();
52 let response = http_client
53 .get(&http_url)
54 .header("Accept", "application/nostr+json")
55 .send()
56 .await
57 .map_err(|e| format!("Failed to fetch NIP-11 document: {}", e))?;
58
50 // 3. Verify 200 OK response 59 // 3. Verify 200 OK response
60 if !response.status().is_success() {
61 return Err(format!(
62 "Expected 200 OK, got {} {}",
63 response.status().as_u16(),
64 response.status().canonical_reason().unwrap_or("Unknown")
65 ));
66 }
67
51 // 4. Verify response is valid JSON 68 // 4. Verify response is valid JSON
52 // 5. Parse as NIP-11 document 69 let json_text = response.text().await
53 // 6. Verify has required fields (name, description, etc.) 70 .map_err(|e| format!("Failed to read response body: {}", e))?;
71
72 let doc: serde_json::Value = serde_json::from_str(&json_text)
73 .map_err(|e| format!("Response is not valid JSON: {}", e))?;
54 74
55 Err("Not implemented yet".to_string()) 75 // 5. Verify has required NIP-11 fields
76 let required_fields = ["name", "description", "software", "version"];
77 for field in &required_fields {
78 if !doc.get(field).is_some() {
79 return Err(format!("Missing required NIP-11 field: {}", field));
80 }
81 }
82
83 Ok(())
56 }) 84 })
57 .await 85 .await
58 } 86 }
@@ -61,22 +89,68 @@ impl Nip11DocumentTests {
61 /// 89 ///
62 /// Spec: Line 12 of ../grasp/01.md 90 /// Spec: Line 12 of ../grasp/01.md
63 /// Requirement: MUST list supported GRASPs as string array 91 /// Requirement: MUST list supported GRASPs as string array
64 async fn test_nip11_supported_grasps_field(_client: &AuditClient) -> TestResult { 92 pub async fn test_nip11_supported_grasps_field(client: &AuditClient) -> TestResult {
65 TestResult::new( 93 TestResult::new(
66 "nip11_supported_grasps_field", 94 "nip11_supported_grasps_field",
67 "GRASP-01:nostr-relay:12", 95 "GRASP-01:nostr-relay:12",
68 "NIP-11 document includes supported_grasps field with GRASP-01", 96 "NIP-11 document includes supported_grasps field with GRASP-01",
69 ) 97 )
70 .run(|| async { 98 .run(|| async {
71 // TODO: Implementation 99 // 1. Fetch NIP-11 document
72 // 1. Fetch NIP-11 document (same as above) 100 let ws_url = client.relay_url().await
101 .map_err(|e| format!("Failed to get relay URL: {}", e))?;
102 let http_url = AuditClient::ws_to_http_url(&ws_url)
103 .map_err(|e| format!("Failed to convert WebSocket URL to HTTP: {}", e))?;
104
105 let http_client = reqwest::Client::new();
106 let response = http_client
107 .get(&http_url)
108 .header("Accept", "application/nostr+json")
109 .send()
110 .await
111 .map_err(|e| format!("Failed to fetch NIP-11 document: {}", e))?;
112
113 let json_text = response.text().await
114 .map_err(|e| format!("Failed to read response body: {}", e))?;
115
116 let doc: serde_json::Value = serde_json::from_str(&json_text)
117 .map_err(|e| format!("Response is not valid JSON: {}", e))?;
118
73 // 2. Verify `supported_grasps` field exists 119 // 2. Verify `supported_grasps` field exists
74 // 3. Verify it's a JSON array of strings 120 let supported_grasps = doc.get("supported_grasps")
121 .ok_or_else(|| "Missing required field: supported_grasps".to_string())?;
122
123 // 3. Verify it's a JSON array
124 let grasps_array = supported_grasps.as_array()
125 .ok_or_else(|| "supported_grasps must be an array".to_string())?;
126
75 // 4. Verify array includes "GRASP-01" 127 // 4. Verify array includes "GRASP-01"
76 // 5. Verify format: each entry matches pattern "GRASP-\d{2}" 128 let grasp_strings: Vec<String> = grasps_array
77 // 6. Document other GRASPs found (for info) 129 .iter()
130 .filter_map(|v| v.as_str().map(|s| s.to_string()))
131 .collect();
132
133 if !grasp_strings.contains(&"GRASP-01".to_string()) {
134 return Err(format!(
135 "supported_grasps must include 'GRASP-01', found: {:?}",
136 grasp_strings
137 ));
138 }
139
140 // 5. Verify format: each entry should match pattern "GRASP-\d{2}"
141 let grasp_pattern = regex::Regex::new(r"^GRASP-\d{2}$")
142 .map_err(|e| format!("Failed to compile regex: {}", e))?;
143
144 for grasp in &grasp_strings {
145 if !grasp_pattern.is_match(grasp) {
146 return Err(format!(
147 "Invalid GRASP format: '{}' (expected GRASP-XX where XX is two digits)",
148 grasp
149 ));
150 }
151 }
78 152
79 Err("Not implemented yet".to_string()) 153 Ok(())
80 }) 154 })
81 .await 155 .await
82 } 156 }
@@ -85,23 +159,47 @@ impl Nip11DocumentTests {
85 /// 159 ///
86 /// Spec: Line 13 of ../grasp/01.md 160 /// Spec: Line 13 of ../grasp/01.md
87 /// Requirement: MUST list repository acceptance criteria 161 /// Requirement: MUST list repository acceptance criteria
88 async fn test_nip11_repo_acceptance_criteria_field(_client: &AuditClient) -> TestResult { 162 pub async fn test_nip11_repo_acceptance_criteria_field(client: &AuditClient) -> TestResult {
89 TestResult::new( 163 TestResult::new(
90 "nip11_repo_acceptance_criteria_field", 164 "nip11_repo_acceptance_criteria_field",
91 "GRASP-01:nostr-relay:13", 165 "GRASP-01:nostr-relay:13",
92 "NIP-11 document includes repo_acceptance_criteria field", 166 "NIP-11 document includes repo_acceptance_criteria field",
93 ) 167 )
94 .run(|| async { 168 .run(|| async {
95 // TODO: Implementation
96 // 1. Fetch NIP-11 document 169 // 1. Fetch NIP-11 document
170 let ws_url = client.relay_url().await
171 .map_err(|e| format!("Failed to get relay URL: {}", e))?;
172 let http_url = AuditClient::ws_to_http_url(&ws_url)
173 .map_err(|e| format!("Failed to convert WebSocket URL to HTTP: {}", e))?;
174
175 let http_client = reqwest::Client::new();
176 let response = http_client
177 .get(&http_url)
178 .header("Accept", "application/nostr+json")
179 .send()
180 .await
181 .map_err(|e| format!("Failed to fetch NIP-11 document: {}", e))?;
182
183 let json_text = response.text().await
184 .map_err(|e| format!("Failed to read response body: {}", e))?;
185
186 let doc: serde_json::Value = serde_json::from_str(&json_text)
187 .map_err(|e| format!("Response is not valid JSON: {}", e))?;
188
97 // 2. Verify `repo_acceptance_criteria` field exists 189 // 2. Verify `repo_acceptance_criteria` field exists
98 // 3. Verify it's a string (human-readable) 190 let criteria = doc.get("repo_acceptance_criteria")
191 .ok_or_else(|| "Missing required field: repo_acceptance_criteria".to_string())?;
192
193 // 3. Verify it's a string
194 let criteria_str = criteria.as_str()
195 .ok_or_else(|| "repo_acceptance_criteria must be a string".to_string())?;
196
99 // 4. Verify non-empty 197 // 4. Verify non-empty
100 // 5. Document the criteria (for info) 198 if criteria_str.trim().is_empty() {
101 // Examples: "Must list this relay in clone and relays tags" 199 return Err("repo_acceptance_criteria must not be empty".to_string());
102 // "Pre-payment required via Lightning invoice" 200 }
103 201
104 Err("Not implemented yet".to_string()) 202 Ok(())
105 }) 203 })
106 .await 204 .await
107 } 205 }
@@ -110,24 +208,47 @@ impl Nip11DocumentTests {
110 /// 208 ///
111 /// Spec: Line 14 of ../grasp/01.md 209 /// Spec: Line 14 of ../grasp/01.md
112 /// Requirement: MUST include curation if curated, omit otherwise 210 /// Requirement: MUST include curation if curated, omit otherwise
113 async fn test_nip11_curation_field(_client: &AuditClient) -> TestResult { 211 pub async fn test_nip11_curation_field(client: &AuditClient) -> TestResult {
114 TestResult::new( 212 TestResult::new(
115 "nip11_curation_field", 213 "nip11_curation_field",
116 "GRASP-01:nostr-relay:14", 214 "GRASP-01:nostr-relay:14",
117 "NIP-11 curation field present if curated, absent otherwise", 215 "NIP-11 curation field present if curated, absent otherwise",
118 ) 216 )
119 .run(|| async { 217 .run(|| async {
120 // TODO: Implementation
121 // 1. Fetch NIP-11 document 218 // 1. Fetch NIP-11 document
219 let ws_url = client.relay_url().await
220 .map_err(|e| format!("Failed to get relay URL: {}", e))?;
221 let http_url = AuditClient::ws_to_http_url(&ws_url)
222 .map_err(|e| format!("Failed to convert WebSocket URL to HTTP: {}", e))?;
223
224 let http_client = reqwest::Client::new();
225 let response = http_client
226 .get(&http_url)
227 .header("Accept", "application/nostr+json")
228 .send()
229 .await
230 .map_err(|e| format!("Failed to fetch NIP-11 document: {}", e))?;
231
232 let json_text = response.text().await
233 .map_err(|e| format!("Failed to read response body: {}", e))?;
234
235 let doc: serde_json::Value = serde_json::from_str(&json_text)
236 .map_err(|e| format!("Response is not valid JSON: {}", e))?;
237
122 // 2. Check if `curation` field exists 238 // 2. Check if `curation` field exists
123 // 3. If present: 239 if let Some(curation) = doc.get("curation") {
124 // - Verify it's a non-empty string 240 // 3. If present: verify it's a non-empty string
125 // - Document the curation policy 241 let curation_str = curation.as_str()
126 // 4. If absent: 242 .ok_or_else(|| "curation field must be a string when present".to_string())?;
127 // - Document that no curation beyond SPAM prevention 243
128 // 5. Both cases are valid per spec 244 if curation_str.trim().is_empty() {
129 245 return Err("curation field must not be empty when present".to_string());
130 Err("Not implemented yet".to_string()) 246 }
247 }
248 // 4. If absent: both cases are valid per spec
249
250 // 5. Both cases are valid - test passes
251 Ok(())
131 }) 252 })
132 .await 253 .await
133 } 254 }
@@ -163,4 +284,4 @@ mod tests {
163 // Don't assert all passed yet - tests not implemented 284 // Don't assert all passed yet - tests not implemented
164 // assert!(results.all_passed(), "Some GRASP-01 NIP-11 document tests failed"); 285 // assert!(results.all_passed(), "Some GRASP-01 NIP-11 document tests failed");
165 } 286 }
166} 287} \ No newline at end of file