diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-21 04:44:40 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-21 04:44:40 +0000 |
| commit | 7dda553918705277c7fa5b903c6a40e4b4a0aa8d (patch) | |
| tree | 4f3511cd3fe56928bd2aa9a22f4ddd592f4c6b83 /grasp-audit/src | |
| parent | 2e799fa7ec57d284c643df8b8dc54471470f5c59 (diff) | |
add nip11
Diffstat (limited to 'grasp-audit/src')
| -rw-r--r-- | grasp-audit/src/client.rs | 19 | ||||
| -rw-r--r-- | grasp-audit/src/specs/grasp01/nip11_document.rs | 189 |
2 files changed, 174 insertions, 34 deletions
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 |