From 7dda553918705277c7fa5b903c6a40e4b4a0aa8d Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 21 Nov 2025 04:44:40 +0000 Subject: add nip11 --- grasp-audit/src/client.rs | 19 +++ grasp-audit/src/specs/grasp01/nip11_document.rs | 189 +++++++++++++++++++----- 2 files changed, 174 insertions(+), 34 deletions(-) (limited to 'grasp-audit/src') 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 { self.keys.public_key() } + /// Get the relay URL + pub async fn relay_url(&self) -> Result { + let relays = self.client.relays().await; + let relay = relays.values().next() + .ok_or_else(|| anyhow!("No relays configured"))?; + Ok(relay.url().to_string()) + } + + /// Convert WebSocket URL to HTTP(S) URL for NIP-11 requests + pub fn ws_to_http_url(ws_url: &str) -> Result { + if ws_url.starts_with("ws://") { + Ok(ws_url.replace("ws://", "http://")) + } else if ws_url.starts_with("wss://") { + Ok(ws_url.replace("wss://", "https://")) + } else { + Err(anyhow!("Invalid WebSocket URL: {}", ws_url)) + } + } + /// Check if connected to relay pub async fn is_connected(&self) -> bool { // 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 { /// /// Spec: Line 11 of ../grasp/01.md /// Requirement: MUST serve NIP-11 document - async fn test_nip11_document_exists(_client: &AuditClient) -> TestResult { + pub async fn test_nip11_document_exists(client: &AuditClient) -> TestResult { TestResult::new( "nip11_document_exists", "GRASP-01:nostr-relay:11", "Serve NIP-11 relay information document", ) .run(|| async { - // TODO: Implementation // 1. Extract HTTP(S) URL from client's WebSocket URL - // - ws://localhost:8081 -> http://localhost:8081 - // - wss://relay.example.com -> https://relay.example.com - // 2. HTTP GET to base URL with header: - // - Accept: application/nostr+json + let ws_url = client.relay_url().await + .map_err(|e| format!("Failed to get relay URL: {}", e))?; + let http_url = AuditClient::ws_to_http_url(&ws_url) + .map_err(|e| format!("Failed to convert WebSocket URL to HTTP: {}", e))?; + + // 2. HTTP GET to base URL with Accept: application/nostr+json header + let http_client = reqwest::Client::new(); + let response = http_client + .get(&http_url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|e| format!("Failed to fetch NIP-11 document: {}", e))?; + // 3. Verify 200 OK response + if !response.status().is_success() { + return Err(format!( + "Expected 200 OK, got {} {}", + response.status().as_u16(), + response.status().canonical_reason().unwrap_or("Unknown") + )); + } + // 4. Verify response is valid JSON - // 5. Parse as NIP-11 document - // 6. Verify has required fields (name, description, etc.) + let json_text = response.text().await + .map_err(|e| format!("Failed to read response body: {}", e))?; + + let doc: serde_json::Value = serde_json::from_str(&json_text) + .map_err(|e| format!("Response is not valid JSON: {}", e))?; - Err("Not implemented yet".to_string()) + // 5. Verify has required NIP-11 fields + let required_fields = ["name", "description", "software", "version"]; + for field in &required_fields { + if !doc.get(field).is_some() { + return Err(format!("Missing required NIP-11 field: {}", field)); + } + } + + Ok(()) }) .await } @@ -61,22 +89,68 @@ impl Nip11DocumentTests { /// /// Spec: Line 12 of ../grasp/01.md /// Requirement: MUST list supported GRASPs as string array - async fn test_nip11_supported_grasps_field(_client: &AuditClient) -> TestResult { + pub async fn test_nip11_supported_grasps_field(client: &AuditClient) -> TestResult { TestResult::new( "nip11_supported_grasps_field", "GRASP-01:nostr-relay:12", "NIP-11 document includes supported_grasps field with GRASP-01", ) .run(|| async { - // TODO: Implementation - // 1. Fetch NIP-11 document (same as above) + // 1. Fetch NIP-11 document + let ws_url = client.relay_url().await + .map_err(|e| format!("Failed to get relay URL: {}", e))?; + let http_url = AuditClient::ws_to_http_url(&ws_url) + .map_err(|e| format!("Failed to convert WebSocket URL to HTTP: {}", e))?; + + let http_client = reqwest::Client::new(); + let response = http_client + .get(&http_url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|e| format!("Failed to fetch NIP-11 document: {}", e))?; + + let json_text = response.text().await + .map_err(|e| format!("Failed to read response body: {}", e))?; + + let doc: serde_json::Value = serde_json::from_str(&json_text) + .map_err(|e| format!("Response is not valid JSON: {}", e))?; + // 2. Verify `supported_grasps` field exists - // 3. Verify it's a JSON array of strings + let supported_grasps = doc.get("supported_grasps") + .ok_or_else(|| "Missing required field: supported_grasps".to_string())?; + + // 3. Verify it's a JSON array + let grasps_array = supported_grasps.as_array() + .ok_or_else(|| "supported_grasps must be an array".to_string())?; + // 4. Verify array includes "GRASP-01" - // 5. Verify format: each entry matches pattern "GRASP-\d{2}" - // 6. Document other GRASPs found (for info) + let grasp_strings: Vec = grasps_array + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + + if !grasp_strings.contains(&"GRASP-01".to_string()) { + return Err(format!( + "supported_grasps must include 'GRASP-01', found: {:?}", + grasp_strings + )); + } + + // 5. Verify format: each entry should match pattern "GRASP-\d{2}" + let grasp_pattern = regex::Regex::new(r"^GRASP-\d{2}$") + .map_err(|e| format!("Failed to compile regex: {}", e))?; + + for grasp in &grasp_strings { + if !grasp_pattern.is_match(grasp) { + return Err(format!( + "Invalid GRASP format: '{}' (expected GRASP-XX where XX is two digits)", + grasp + )); + } + } - Err("Not implemented yet".to_string()) + Ok(()) }) .await } @@ -85,23 +159,47 @@ impl Nip11DocumentTests { /// /// Spec: Line 13 of ../grasp/01.md /// Requirement: MUST list repository acceptance criteria - async fn test_nip11_repo_acceptance_criteria_field(_client: &AuditClient) -> TestResult { + pub async fn test_nip11_repo_acceptance_criteria_field(client: &AuditClient) -> TestResult { TestResult::new( "nip11_repo_acceptance_criteria_field", "GRASP-01:nostr-relay:13", "NIP-11 document includes repo_acceptance_criteria field", ) .run(|| async { - // TODO: Implementation // 1. Fetch NIP-11 document + let ws_url = client.relay_url().await + .map_err(|e| format!("Failed to get relay URL: {}", e))?; + let http_url = AuditClient::ws_to_http_url(&ws_url) + .map_err(|e| format!("Failed to convert WebSocket URL to HTTP: {}", e))?; + + let http_client = reqwest::Client::new(); + let response = http_client + .get(&http_url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|e| format!("Failed to fetch NIP-11 document: {}", e))?; + + let json_text = response.text().await + .map_err(|e| format!("Failed to read response body: {}", e))?; + + let doc: serde_json::Value = serde_json::from_str(&json_text) + .map_err(|e| format!("Response is not valid JSON: {}", e))?; + // 2. Verify `repo_acceptance_criteria` field exists - // 3. Verify it's a string (human-readable) + let criteria = doc.get("repo_acceptance_criteria") + .ok_or_else(|| "Missing required field: repo_acceptance_criteria".to_string())?; + + // 3. Verify it's a string + let criteria_str = criteria.as_str() + .ok_or_else(|| "repo_acceptance_criteria must be a string".to_string())?; + // 4. Verify non-empty - // 5. Document the criteria (for info) - // Examples: "Must list this relay in clone and relays tags" - // "Pre-payment required via Lightning invoice" + if criteria_str.trim().is_empty() { + return Err("repo_acceptance_criteria must not be empty".to_string()); + } - Err("Not implemented yet".to_string()) + Ok(()) }) .await } @@ -110,24 +208,47 @@ impl Nip11DocumentTests { /// /// Spec: Line 14 of ../grasp/01.md /// Requirement: MUST include curation if curated, omit otherwise - async fn test_nip11_curation_field(_client: &AuditClient) -> TestResult { + pub async fn test_nip11_curation_field(client: &AuditClient) -> TestResult { TestResult::new( "nip11_curation_field", "GRASP-01:nostr-relay:14", "NIP-11 curation field present if curated, absent otherwise", ) .run(|| async { - // TODO: Implementation // 1. Fetch NIP-11 document + let ws_url = client.relay_url().await + .map_err(|e| format!("Failed to get relay URL: {}", e))?; + let http_url = AuditClient::ws_to_http_url(&ws_url) + .map_err(|e| format!("Failed to convert WebSocket URL to HTTP: {}", e))?; + + let http_client = reqwest::Client::new(); + let response = http_client + .get(&http_url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|e| format!("Failed to fetch NIP-11 document: {}", e))?; + + let json_text = response.text().await + .map_err(|e| format!("Failed to read response body: {}", e))?; + + let doc: serde_json::Value = serde_json::from_str(&json_text) + .map_err(|e| format!("Response is not valid JSON: {}", e))?; + // 2. Check if `curation` field exists - // 3. If present: - // - Verify it's a non-empty string - // - Document the curation policy - // 4. If absent: - // - Document that no curation beyond SPAM prevention - // 5. Both cases are valid per spec - - Err("Not implemented yet".to_string()) + if let Some(curation) = doc.get("curation") { + // 3. If present: verify it's a non-empty string + let curation_str = curation.as_str() + .ok_or_else(|| "curation field must be a string when present".to_string())?; + + if curation_str.trim().is_empty() { + return Err("curation field must not be empty when present".to_string()); + } + } + // 4. If absent: both cases are valid per spec + + // 5. Both cases are valid - test passes + Ok(()) }) .await } @@ -163,4 +284,4 @@ mod tests { // Don't assert all passed yet - tests not implemented // assert!(results.all_passed(), "Some GRASP-01 NIP-11 document tests failed"); } -} +} \ No newline at end of file -- cgit v1.2.3