diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 7cf3e3f7e8..b433b534f8 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -859,6 +859,14 @@ pub async fn create_auth_event( state: State<'_, AppState>, ) -> Result { let keys = state.signing_keys()?; + // The relay-WS reconnection handshake signs a NIP-42 auth event under a + // per-send bounded egress lease: reconnection cannot re-authenticate + // mid-transition because the drain refuses new admission (spec — the + // frontend session's authority is gated the same as every other owner + // sign). Frontend session REGISTRATION (capability + native-WS teardown + // handle) defers to C6/C7 with the frontend identity store; this lands the + // spec-mandated admission gate now. + let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; tauri::async_runtime::spawn_blocking(move || { let tags = vec![ @@ -872,7 +880,8 @@ pub async fn create_auth_event( .tags(tags) .sign_with_keys(&keys) .map_err(|error| format!("sign failed: {error}"))?; - + // Sign complete: the lease has served its sign→return window. + drop(lease); Ok(event.as_json()) }) .await @@ -915,89 +924,8 @@ pub async fn nip44_decrypt_from_self( } #[cfg(test)] -mod nostr_identity_binding_tests { - use super::build_nostr_identity_binding_event; - use crate::nostr_bind; - use nostr::{JsonUtil, Keys}; - - fn tag_values(event: &nostr::Event) -> Vec> { - event - .tags - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect() - } - - #[test] - fn build_nostr_identity_binding_event_signs_exact_shape() { - let keys = Keys::generate(); - let event = build_nostr_identity_binding_event( - &keys, - "550e8400-e29b-41d4-a716-446655440000", - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", - "123456", - "https://example.com", - "2999-01-01T00:00:00Z", - ) - .unwrap(); - - assert_eq!(event.kind.as_u16(), nostr_bind::KIND); - assert_eq!(event.content, nostr_bind::CONTENT); - assert_eq!(event.pubkey, keys.public_key()); - assert!(event.verify_id()); - assert!(event.verify_signature()); - assert!(nostr::Event::from_json(event.as_json()).is_ok()); - - let tags = tag_values(&event); - assert!(tags.contains(&vec![ - "challenge_id".into(), - "550e8400-e29b-41d4-a716-446655440000".into(), - ])); - assert!(tags.contains(&vec![ - "nonce".into(), - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567".into(), - ])); - assert!(tags.contains(&vec!["verification_code".into(), "123456".into(),])); - assert!(tags.contains(&vec!["audience".into(), "buzz:nostr-identity".into()])); - assert!(tags.contains(&vec!["action".into(), "bind_nostr_identity".into(),])); - assert!(tags.contains(&vec!["protocol".into(), "buzz-nostr-identity".into(),])); - assert!(tags.contains(&vec!["version".into(), "1".into(),])); - assert!(tags.contains(&vec!["origin".into(), "https://example.com".into(),])); - assert!(tags.contains(&vec!["expires_at".into(), "2999-01-01T00:00:00Z".into(),])); - } - - #[test] - fn build_nostr_identity_binding_event_rejects_malformed_verification_code() { - let keys = Keys::generate(); - let error = build_nostr_identity_binding_event( - &keys, - "550e8400-e29b-41d4-a716-446655440000", - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", - "12345a", - "https://example.com", - "2999-01-01T00:00:00Z", - ) - .unwrap_err(); - - assert_eq!(error, "verification_code must be exactly 6 digits"); - } - - #[test] - fn build_nostr_identity_binding_event_rejects_expired_link() { - let keys = Keys::generate(); - let error = build_nostr_identity_binding_event( - &keys, - "550e8400-e29b-41d4-a716-446655440000", - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", - "123456", - "https://example.com", - "2000-01-01T00:00:00Z", - ) - .unwrap_err(); - - assert_eq!(error, "expires_at is expired"); - } -} +#[path = "identity_binding_tests.rs"] +mod nostr_identity_binding_tests; #[cfg(test)] #[path = "identity_key_backup_tests.rs"] diff --git a/desktop/src-tauri/src/commands/identity_binding_tests.rs b/desktop/src-tauri/src/commands/identity_binding_tests.rs new file mode 100644 index 0000000000..1c2c08c1cd --- /dev/null +++ b/desktop/src-tauri/src/commands/identity_binding_tests.rs @@ -0,0 +1,84 @@ +//! Unit tests for `build_nostr_identity_binding_event`. Split from +//! `identity.rs` for file-size discipline; wired via `#[path]`. + +use super::build_nostr_identity_binding_event; +use crate::nostr_bind; +use nostr::{JsonUtil, Keys}; + +fn tag_values(event: &nostr::Event) -> Vec> { + event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect() +} + +#[test] +fn build_nostr_identity_binding_event_signs_exact_shape() { + let keys = Keys::generate(); + let event = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "123456", + "https://example.com", + "2999-01-01T00:00:00Z", + ) + .unwrap(); + + assert_eq!(event.kind.as_u16(), nostr_bind::KIND); + assert_eq!(event.content, nostr_bind::CONTENT); + assert_eq!(event.pubkey, keys.public_key()); + assert!(event.verify_id()); + assert!(event.verify_signature()); + assert!(nostr::Event::from_json(event.as_json()).is_ok()); + + let tags = tag_values(&event); + assert!(tags.contains(&vec![ + "challenge_id".into(), + "550e8400-e29b-41d4-a716-446655440000".into(), + ])); + assert!(tags.contains(&vec![ + "nonce".into(), + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567".into(), + ])); + assert!(tags.contains(&vec!["verification_code".into(), "123456".into(),])); + assert!(tags.contains(&vec!["audience".into(), "buzz:nostr-identity".into()])); + assert!(tags.contains(&vec!["action".into(), "bind_nostr_identity".into(),])); + assert!(tags.contains(&vec!["protocol".into(), "buzz-nostr-identity".into(),])); + assert!(tags.contains(&vec!["version".into(), "1".into(),])); + assert!(tags.contains(&vec!["origin".into(), "https://example.com".into(),])); + assert!(tags.contains(&vec!["expires_at".into(), "2999-01-01T00:00:00Z".into(),])); +} + +#[test] +fn build_nostr_identity_binding_event_rejects_malformed_verification_code() { + let keys = Keys::generate(); + let error = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "12345a", + "https://example.com", + "2999-01-01T00:00:00Z", + ) + .unwrap_err(); + + assert_eq!(error, "verification_code must be exactly 6 digits"); +} + +#[test] +fn build_nostr_identity_binding_event_rejects_expired_link() { + let keys = Keys::generate(); + let error = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "123456", + "https://example.com", + "2000-01-01T00:00:00Z", + ) + .unwrap_err(); + + assert_eq!(error, "expires_at is expired"); +} diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 1d5f7aaa42..e886523b0d 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -379,9 +379,10 @@ pub(crate) async fn mint_media_get_auth( } }; // Issuance runs under a bounded egress lease: signing the bearer is an - // ordinary leased operation (spec L4569-4570). Registering the durable - // capability while the lease is held keeps its generation stamp coherent - // with the state that admitted the sign — no bump can slip between. + // ordinary leased operation (spec L4569-4570). The durable capability is + // stamped with the LEASE's admission generation, not a re-read one — so a + // transition that bumps the generation between admission and registration + // stamps a stale value and the first admit_exercise refuses (fail-closed). let lease = match crate::owner_identity_egress::try_admit_owner_identity_egress().await { Ok(lease) => lease, Err(e) => { @@ -396,7 +397,7 @@ pub(crate) async fn mint_media_get_auth( return None; } }; - let bearer = crate::owner_identity_egress::register_owner_bearer(); + let bearer = crate::owner_identity_egress::register_owner_bearer(&lease); drop(lease); Some((header, bearer)) } @@ -472,7 +473,7 @@ async fn do_upload( let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; let keys = state.signing_keys()?; let event = sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)?; - let bearer = crate::owner_identity_egress::register_owner_bearer(); + let bearer = crate::owner_identity_egress::register_owner_bearer(&lease); drop(lease); (event, bearer) }; @@ -503,6 +504,10 @@ async fn do_upload( ) .await?; if should_retry_legacy_upload(resp.status()) { + // Re-validate before the second transmission: the same signed header is + // resent, and a transition may have superseded the bearer between the + // two dispatches. Two transmissions, two validations. + bearer.admit_exercise()?; resp = send_upload_attempt( state, UploadAttempt { @@ -837,181 +842,5 @@ pub(super) async fn upload_media_bytes_inner( // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_server_authority_default_ports() { - assert_eq!( - extract_server_authority("https://relay.example.com"), - Some("relay.example.com".to_string()) - ); - assert_eq!( - extract_server_authority("https://relay.example.com:443"), - Some("relay.example.com".to_string()) - ); - assert_eq!( - extract_server_authority("http://relay.example.com:80"), - Some("relay.example.com".to_string()) - ); - } - - #[test] - fn test_extract_server_authority_non_default_ports() { - assert_eq!( - extract_server_authority("http://localhost:3000"), - Some("localhost:3000".to_string()) - ); - assert_eq!( - extract_server_authority("https://relay.example.com:8443"), - Some("relay.example.com:8443".to_string()) - ); - } - - #[test] - fn test_extract_server_authority_ipv6() { - assert_eq!( - extract_server_authority("http://[::1]:3000"), - Some("[::1]:3000".to_string()) - ); - } - - #[test] - fn test_extract_server_authority_invalid() { - assert_eq!(extract_server_authority("not-a-url"), None); - assert_eq!(extract_server_authority(""), None); - } - - #[test] - fn test_sign_blossom_get_auth_header_shape() { - let keys = Keys::generate(); - let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 600).unwrap(); - let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix"); - let json = URL_SAFE_NO_PAD.decode(b64).unwrap(); - let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); - - assert_eq!(event.kind, Kind::from(24242)); - event.verify().expect("valid signature"); - - let tag = |name: &str| -> Option { - event.tags.iter().find_map(|t| { - let v = t.as_slice(); - (v.first().map(String::as_str) == Some(name)).then(|| v[1].clone()) - }) - }; - assert_eq!(tag("t").as_deref(), Some("get")); - assert_eq!(tag("server").as_deref(), Some("localhost:3000")); - // Server-scoped token: no x tag (BUD-01 allows x OR server). - assert!(tag("x").is_none()); - let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); - let now = Timestamp::now().as_secs(); - assert!(expiration > now && expiration <= now + 600); - } - - #[test] - fn test_sign_blossom_get_auth_header_invalid_base_url() { - let keys = Keys::generate(); - assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 600).is_err()); - } - - #[test] - fn test_detect_and_validate_mime_jpeg() { - // Minimal JPEG: SOI + EOI - let jpeg = [0xFF, 0xD8, 0xFF, 0xE0]; - assert_eq!(detect_and_validate_mime(&jpeg).unwrap(), "image/jpeg"); - } - - #[test] - fn test_detect_and_validate_mime_accepts_text_as_octet_stream() { - // Plain text has no magic bytes — infer returns None, so it's accepted - // as opaque binary (served as a download). This is the common Slack case. - let text = b"hello world"; - assert_eq!( - detect_and_validate_mime(text).unwrap(), - "application/octet-stream" - ); - } - - #[test] - fn test_detect_and_validate_mime_rejects_html() { - let html = b""; - assert!(detect_and_validate_mime(html).is_err()); - } - - #[test] - fn test_image_sanitizer_bakes_exif_orientation() { - let source = image::RgbImage::from_fn(2, 3, |x, y| { - image::Rgb([(x * 80) as u8, (y * 60) as u8, 32]) - }); - let mut encoded = Vec::new(); - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 95) - .encode_image(&source) - .unwrap(); - - // Minimal little-endian Exif IFD with Orientation=6 (rotate 90°). - let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0".to_vec(); - exif.extend_from_slice(&[ - 0x12, 0x01, // Orientation tag - 0x03, 0x00, // SHORT - 0x01, 0x00, 0x00, 0x00, // count=1 - 0x06, 0x00, 0x00, 0x00, // value=6 - 0x00, 0x00, 0x00, 0x00, // next IFD - ]); - let segment_len = (exif.len() + 2) as u16; - let mut oriented = encoded[..2].to_vec(); - oriented.extend_from_slice(&[0xff, 0xe1]); - oriented.extend_from_slice(&segment_len.to_be_bytes()); - oriented.extend_from_slice(&exif); - oriented.extend_from_slice(&encoded[2..]); - - let sanitized = sanitize_image_for_upload(oriented, "image/jpeg").unwrap(); - let decoded = - image::load_from_memory_with_format(&sanitized, image::ImageFormat::Jpeg).unwrap(); - assert_eq!((decoded.width(), decoded.height()), (3, 2)); - assert!(!sanitized.windows(6).any(|bytes| bytes == b"Exif\0\0")); - } - - #[test] - fn test_animated_png_and_webp_are_not_flattened() { - let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); - apng.extend_from_slice(&8u32.to_be_bytes()); - apng.extend_from_slice(b"acTL"); - apng.extend_from_slice(&[0; 8]); - apng.extend_from_slice(&[0; 4]); - assert!(is_animated_image(&apng, "image/png")); - assert!(sanitize_image_for_upload(apng, "image/png").is_ok()); - - let mut webp = b"RIFF\x0c\0\0\0WEBPANIM".to_vec(); - webp.extend_from_slice(&0u32.to_le_bytes()); - assert!(is_animated_image(&webp, "image/webp")); - assert!(sanitize_image_for_upload(webp, "image/webp").is_ok()); - } - - #[test] - fn test_legacy_upload_retry_statuses_are_narrow() { - assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND)); - assert!(should_retry_legacy_upload( - reqwest::StatusCode::METHOD_NOT_ALLOWED - )); - assert!(!should_retry_legacy_upload( - reqwest::StatusCode::UNPROCESSABLE_ENTITY - )); - assert!(!should_retry_legacy_upload( - reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE - )); - } - - #[test] - fn test_sanitize_filename() { - assert_eq!(sanitize_filename("report.pdf"), "report.pdf"); - // Strips directory components and traversal. - assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); - assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt"); - assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx"); - // Empty / separator-only falls back. - assert_eq!(sanitize_filename(""), "file"); - assert_eq!(sanitize_filename("/"), "file"); - // Control chars removed. - assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt"); - } -} +#[path = "media_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/media_tests.rs b/desktop/src-tauri/src/commands/media_tests.rs new file mode 100644 index 0000000000..e8ed81f5d1 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_tests.rs @@ -0,0 +1,179 @@ +//! Unit tests for the media command module. Split from `media.rs` for +//! file-size discipline; wired via `#[path]` from that file's `mod tests`. + +use super::*; + +#[test] +fn test_extract_server_authority_default_ports() { + assert_eq!( + extract_server_authority("https://relay.example.com"), + Some("relay.example.com".to_string()) + ); + assert_eq!( + extract_server_authority("https://relay.example.com:443"), + Some("relay.example.com".to_string()) + ); + assert_eq!( + extract_server_authority("http://relay.example.com:80"), + Some("relay.example.com".to_string()) + ); +} + +#[test] +fn test_extract_server_authority_non_default_ports() { + assert_eq!( + extract_server_authority("http://localhost:3000"), + Some("localhost:3000".to_string()) + ); + assert_eq!( + extract_server_authority("https://relay.example.com:8443"), + Some("relay.example.com:8443".to_string()) + ); +} + +#[test] +fn test_extract_server_authority_ipv6() { + assert_eq!( + extract_server_authority("http://[::1]:3000"), + Some("[::1]:3000".to_string()) + ); +} + +#[test] +fn test_extract_server_authority_invalid() { + assert_eq!(extract_server_authority("not-a-url"), None); + assert_eq!(extract_server_authority(""), None); +} + +#[test] +fn test_sign_blossom_get_auth_header_shape() { + let keys = Keys::generate(); + let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 600).unwrap(); + let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix"); + let json = URL_SAFE_NO_PAD.decode(b64).unwrap(); + let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); + + assert_eq!(event.kind, Kind::from(24242)); + event.verify().expect("valid signature"); + + let tag = |name: &str| -> Option { + event.tags.iter().find_map(|t| { + let v = t.as_slice(); + (v.first().map(String::as_str) == Some(name)).then(|| v[1].clone()) + }) + }; + assert_eq!(tag("t").as_deref(), Some("get")); + assert_eq!(tag("server").as_deref(), Some("localhost:3000")); + // Server-scoped token: no x tag (BUD-01 allows x OR server). + assert!(tag("x").is_none()); + let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); + let now = Timestamp::now().as_secs(); + assert!(expiration > now && expiration <= now + 600); +} + +#[test] +fn test_sign_blossom_get_auth_header_invalid_base_url() { + let keys = Keys::generate(); + assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 600).is_err()); +} + +#[test] +fn test_detect_and_validate_mime_jpeg() { + // Minimal JPEG: SOI + EOI + let jpeg = [0xFF, 0xD8, 0xFF, 0xE0]; + assert_eq!(detect_and_validate_mime(&jpeg).unwrap(), "image/jpeg"); +} + +#[test] +fn test_detect_and_validate_mime_accepts_text_as_octet_stream() { + // Plain text has no magic bytes — infer returns None, so it's accepted + // as opaque binary (served as a download). This is the common Slack case. + let text = b"hello world"; + assert_eq!( + detect_and_validate_mime(text).unwrap(), + "application/octet-stream" + ); +} + +#[test] +fn test_detect_and_validate_mime_rejects_html() { + let html = b""; + assert!(detect_and_validate_mime(html).is_err()); +} + +#[test] +fn test_image_sanitizer_bakes_exif_orientation() { + let source = image::RgbImage::from_fn(2, 3, |x, y| { + image::Rgb([(x * 80) as u8, (y * 60) as u8, 32]) + }); + let mut encoded = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 95) + .encode_image(&source) + .unwrap(); + + // Minimal little-endian Exif IFD with Orientation=6 (rotate 90°). + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0".to_vec(); + exif.extend_from_slice(&[ + 0x12, 0x01, // Orientation tag + 0x03, 0x00, // SHORT + 0x01, 0x00, 0x00, 0x00, // count=1 + 0x06, 0x00, 0x00, 0x00, // value=6 + 0x00, 0x00, 0x00, 0x00, // next IFD + ]); + let segment_len = (exif.len() + 2) as u16; + let mut oriented = encoded[..2].to_vec(); + oriented.extend_from_slice(&[0xff, 0xe1]); + oriented.extend_from_slice(&segment_len.to_be_bytes()); + oriented.extend_from_slice(&exif); + oriented.extend_from_slice(&encoded[2..]); + + let sanitized = sanitize_image_for_upload(oriented, "image/jpeg").unwrap(); + let decoded = + image::load_from_memory_with_format(&sanitized, image::ImageFormat::Jpeg).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (3, 2)); + assert!(!sanitized.windows(6).any(|bytes| bytes == b"Exif\0\0")); +} + +#[test] +fn test_animated_png_and_webp_are_not_flattened() { + let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); + apng.extend_from_slice(&8u32.to_be_bytes()); + apng.extend_from_slice(b"acTL"); + apng.extend_from_slice(&[0; 8]); + apng.extend_from_slice(&[0; 4]); + assert!(is_animated_image(&apng, "image/png")); + assert!(sanitize_image_for_upload(apng, "image/png").is_ok()); + + let mut webp = b"RIFF\x0c\0\0\0WEBPANIM".to_vec(); + webp.extend_from_slice(&0u32.to_le_bytes()); + assert!(is_animated_image(&webp, "image/webp")); + assert!(sanitize_image_for_upload(webp, "image/webp").is_ok()); +} + +#[test] +fn test_legacy_upload_retry_statuses_are_narrow() { + assert!(should_retry_legacy_upload(reqwest::StatusCode::NOT_FOUND)); + assert!(should_retry_legacy_upload( + reqwest::StatusCode::METHOD_NOT_ALLOWED + )); + assert!(!should_retry_legacy_upload( + reqwest::StatusCode::UNPROCESSABLE_ENTITY + )); + assert!(!should_retry_legacy_upload( + reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE + )); +} + +#[test] +fn test_sanitize_filename() { + assert_eq!(sanitize_filename("report.pdf"), "report.pdf"); + // Strips directory components and traversal. + assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); + assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt"); + assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx"); + // Empty / separator-only falls back. + assert_eq!(sanitize_filename(""), "file"); + assert_eq!(sanitize_filename("/"), "file"); + // Control chars removed. + assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt"); +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b23f9f693d..08a7645810 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -978,36 +978,9 @@ pub async fn delete_message( // ── Local helpers ─────────────────────────────────────────────────────────── -fn channel_id_from_tags(ev: &nostr::Event) -> Option { - ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "h" { - Some(s[1].clone()) - } else { - None - } - }) -} +mod feed_item; +use feed_item::feed_item_from_event; -fn tags_to_vec(ev: &nostr::Event) -> Vec> { - ev.tags.iter().map(|t| t.as_slice().to_vec()).collect() -} - -fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo { - let channel_id = channel_id_from_tags(ev); - FeedItemInfo { - id: ev.id.to_hex(), - kind: ev.kind.as_u16() as u32, - pubkey: ev.pubkey.to_hex(), - content: ev.content.clone(), - created_at: ev.created_at.as_secs(), - channel_id, - channel_name: String::new(), - channel_type: None, - tags: tags_to_vec(ev), - category: category.to_string(), - } -} #[cfg(test)] #[path = "messages_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/messages/feed_item.rs b/desktop/src-tauri/src/commands/messages/feed_item.rs new file mode 100644 index 0000000000..557d4b1fa7 --- /dev/null +++ b/desktop/src-tauri/src/commands/messages/feed_item.rs @@ -0,0 +1,35 @@ +//! Feed-item projection helpers: turn a raw nostr event into the +//! `FeedItemInfo` wire shape the feed reads return. + +use crate::models::FeedItemInfo; + +fn channel_id_from_tags(ev: &nostr::Event) -> Option { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "h" { + Some(s[1].clone()) + } else { + None + } + }) +} + +fn tags_to_vec(ev: &nostr::Event) -> Vec> { + ev.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +pub(super) fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo { + let channel_id = channel_id_from_tags(ev); + FeedItemInfo { + id: ev.id.to_hex(), + kind: ev.kind.as_u16() as u32, + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + created_at: ev.created_at.as_secs(), + channel_id, + channel_name: String::new(), + channel_type: None, + tags: tags_to_vec(ev), + category: category.to_string(), + } +} diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index a0bc2daf15..45e949fecd 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -24,7 +24,7 @@ //! It is never logged. use base64::{engine::general_purpose::STANDARD, Engine as _}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tauri::{AppHandle, State}; use super::super::export_util::save_bytes_with_dialog; @@ -48,6 +48,9 @@ use crate::{ }, }; +mod archive; +pub use archive::{list_agent_cards, load_agent_card}; + /// The Buzz card frame template — Tyler's gold-honeycomb base. Generation /// input only: it never participates in the snapshot manifest, PNG chunk, /// import decoder, or attachment validation. Embedded at compile time for @@ -92,174 +95,6 @@ pub struct MintedCard { pub memory_level: MemoryLevel, } -// ── Card archive ────────────────────────────────────────────────────────────── - -/// Sidecar metadata for one archived card PNG. Stored as `.json` next -/// to `.agent.png` in the cards dir — two plain files per mint, no -/// shared index to corrupt. Listing scans sidecars; a card whose PNG is -/// missing is skipped rather than failing the whole list. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArchivedCardMeta { - /// Unique on-disk PNG file name within the cards dir. - pub stored_file_name: String, - /// Suggested save-as name, e.g. `eva.agent.png`. - pub file_name: String, - /// The id the card was minted for (instance pubkey or definition slug). - pub agent_id: String, - pub agent_name: String, - pub designer_notes: String, - pub locked: bool, - /// Memory embedded in this card's snapshot. Defaults to `None` when the - /// sidecar predates the field — every pre-field mint was minted with - /// `MemoryLevel::None` (it was structural), so the default is honest. - #[serde(default)] - pub memory_level: MemoryLevel, - /// ISO-8601 mint timestamp. - pub minted_at: String, - /// Small JPEG preview for gallery grids, base64. Populated by - /// `list_agent_cards` from the sidecar thumb file — never stored in the - /// JSON sidecar itself. - #[serde(default, skip_deserializing)] - pub thumb_jpeg_base64: Option, -} - -fn cards_dir(app: &AppHandle) -> Result { - let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("cards"); - std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create cards dir: {e}"))?; - Ok(dir) -} - -/// Persist a freshly minted card to the archive. Failures are surfaced to the -/// caller (which logs and continues) — an archive write must never fail a -/// mint the user already paid for. -fn archive_minted_card( - app: &AppHandle, - agent_id: &str, - agent_name: &str, - card: &MintedCard, - bytes: &[u8], -) -> Result { - let dir = cards_dir(app)?; - let stem = format!( - "{}-{}", - crate::util::slugify(agent_name, "agent", 50), - uuid::Uuid::new_v4() - ); - let stored_file_name = format!("{stem}.agent.png"); - let meta = ArchivedCardMeta { - stored_file_name: stored_file_name.clone(), - file_name: card.file_name.clone(), - agent_id: agent_id.to_string(), - agent_name: agent_name.to_string(), - designer_notes: card.designer_notes.clone(), - locked: card.locked, - memory_level: card.memory_level, - minted_at: crate::util::now_iso(), - thumb_jpeg_base64: None, - }; - // PNG first, sidecar second: a crash between the two leaves an orphaned - // PNG (invisible to the list), never a sidecar pointing at nothing. - std::fs::write(dir.join(&stored_file_name), bytes) - .map_err(|e| format!("failed to write archived card: {e}"))?; - let meta_json = serde_json::to_string_pretty(&meta) - .map_err(|e| format!("failed to serialize card metadata: {e}"))?; - std::fs::write(dir.join(format!("{stem}.json")), meta_json) - .map_err(|e| format!("failed to write card metadata: {e}"))?; - // Thumb last and best-effort: the gallery grid falls back to lazy - // full-card loading for a card whose thumb is missing. - if let Ok(thumb) = encode_card_thumb(bytes) { - let _ = std::fs::write(dir.join(format!("{stem}.thumb.jpg")), thumb); - } - Ok(meta) -} - -/// Downscale card PNG bytes to a small JPEG for gallery grids. The full card -/// is ~1500x2250 PNG (megabytes); shipping that per card over IPC just to -/// draw a grid tile is waste. -fn encode_card_thumb(bytes: &[u8]) -> Result, String> { - const THUMB_WIDTH: u32 = 300; - let img = image::load_from_memory(bytes).map_err(|e| format!("thumb decode: {e}"))?; - let scale = THUMB_WIDTH as f64 / img.width() as f64; - let thumb = img.resize( - THUMB_WIDTH, - (img.height() as f64 * scale).round().max(1.0) as u32, - image::imageops::FilterType::Triangle, - ); - let mut out = Vec::new(); - // JPEG has no alpha; cards are opaque, so flatten unconditionally. - let rgb = image::DynamicImage::ImageRgb8(thumb.to_rgb8()); - rgb.write_to( - &mut std::io::Cursor::new(&mut out), - image::ImageFormat::Jpeg, - ) - .map_err(|e| format!("thumb encode: {e}"))?; - Ok(out) -} - -/// Reject any archive file name that could escape the cards dir or name a -/// non-archive file. Archive names are generated by `archive_minted_card` -/// (slug + UUID), so a strict shape check loses nothing legitimate. -fn validate_archive_file_name(stored_file_name: &str) -> Result<(), String> { - let valid = stored_file_name.ends_with(".agent.png") - && !stored_file_name.contains(['/', '\\']) - && !stored_file_name.contains(".."); - if !valid { - return Err("Invalid archived card file name.".to_string()); - } - Ok(()) -} - -/// List all archived cards, newest first. -#[tauri::command] -pub fn list_agent_cards(app: AppHandle) -> Result, String> { - let dir = cards_dir(&app)?; - let entries = std::fs::read_dir(&dir).map_err(|e| format!("failed to read cards dir: {e}"))?; - let mut cards = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - let Ok(content) = std::fs::read_to_string(&path) else { - continue; - }; - let Ok(meta) = serde_json::from_str::(&content) else { - // A malformed sidecar hides one card, never the archive. - eprintln!( - "buzz-desktop: card-archive: skipping malformed sidecar {}", - path.display() - ); - continue; - }; - let mut meta = meta; - if validate_archive_file_name(&meta.stored_file_name).is_ok() - && dir.join(&meta.stored_file_name).is_file() - { - // Attach the pre-rendered grid thumb when present (best-effort). - if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { - meta.thumb_jpeg_base64 = std::fs::read(dir.join(format!("{stem}.thumb.jpg"))) - .ok() - .map(|b| STANDARD.encode(&b)); - } - cards.push(meta); - } - } - // ISO-8601 sorts lexicographically; newest first. - cards.sort_by(|a, b| b.minted_at.cmp(&a.minted_at)); - Ok(cards) -} - -/// Load one archived card's PNG bytes as base64, keyed by its stored file -/// name (as returned by `list_agent_cards`). -#[tauri::command] -pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result { - validate_archive_file_name(&stored_file_name)?; - let bytes = std::fs::read(cards_dir(&app)?.join(&stored_file_name)) - .map_err(|e| format!("failed to read archived card: {e}"))?; - Ok(STANDARD.encode(&bytes)) -} - // ── Key resolution ──────────────────────────────────────────────────────────── /// Pure layering: global env < persona env < agent record env, then the @@ -854,7 +689,7 @@ pub async fn mint_agent_card( // Archive best-effort: the mint is already paid for and verified, so a // failed archive write logs and continues — it never fails the mint. - if let Err(e) = archive_minted_card(&app, &id, &display_name, &minted, &final_bytes) { + if let Err(e) = archive::archive_minted_card(&app, &id, &display_name, &minted, &final_bytes) { eprintln!("buzz-desktop: card-archive: failed to archive minted card: {e}"); } diff --git a/desktop/src-tauri/src/commands/personas/card/archive.rs b/desktop/src-tauri/src/commands/personas/card/archive.rs new file mode 100644 index 0000000000..c9714c5abf --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card/archive.rs @@ -0,0 +1,230 @@ +//! Card archive: on-disk persistence for minted trading cards. +//! +//! Each mint writes two plain files to the cards dir — `.agent.png` +//! and a `.json` sidecar — plus a best-effort `.thumb.jpg` for +//! gallery grids. There is no shared index to corrupt: listing scans +//! sidecars, and a card whose PNG is missing is skipped rather than failing +//! the whole list. + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +use super::MintedCard; +use crate::managed_agents::agent_snapshot::MemoryLevel; + +/// Sidecar metadata for one archived card PNG. Stored as `.json` next +/// to `.agent.png` in the cards dir — two plain files per mint, no +/// shared index to corrupt. Listing scans sidecars; a card whose PNG is +/// missing is skipped rather than failing the whole list. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedCardMeta { + /// Unique on-disk PNG file name within the cards dir. + pub stored_file_name: String, + /// Suggested save-as name, e.g. `eva.agent.png`. + pub file_name: String, + /// The id the card was minted for (instance pubkey or definition slug). + pub agent_id: String, + pub agent_name: String, + pub designer_notes: String, + pub locked: bool, + /// Memory embedded in this card's snapshot. Defaults to `None` when the + /// sidecar predates the field — every pre-field mint was minted with + /// `MemoryLevel::None` (it was structural), so the default is honest. + #[serde(default)] + pub memory_level: MemoryLevel, + /// ISO-8601 mint timestamp. + pub minted_at: String, + /// Small JPEG preview for gallery grids, base64. Populated by + /// `list_agent_cards` from the sidecar thumb file — never stored in the + /// JSON sidecar itself. + #[serde(default, skip_deserializing)] + pub thumb_jpeg_base64: Option, +} + +fn cards_dir(app: &AppHandle) -> Result { + let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("cards"); + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create cards dir: {e}"))?; + Ok(dir) +} + +/// Persist a freshly minted card to the archive. Failures are surfaced to the +/// caller (which logs and continues) — an archive write must never fail a +/// mint the user already paid for. +pub(super) fn archive_minted_card( + app: &AppHandle, + agent_id: &str, + agent_name: &str, + card: &MintedCard, + bytes: &[u8], +) -> Result { + let dir = cards_dir(app)?; + let stem = format!( + "{}-{}", + crate::util::slugify(agent_name, "agent", 50), + uuid::Uuid::new_v4() + ); + let stored_file_name = format!("{stem}.agent.png"); + let meta = ArchivedCardMeta { + stored_file_name: stored_file_name.clone(), + file_name: card.file_name.clone(), + agent_id: agent_id.to_string(), + agent_name: agent_name.to_string(), + designer_notes: card.designer_notes.clone(), + locked: card.locked, + memory_level: card.memory_level, + minted_at: crate::util::now_iso(), + thumb_jpeg_base64: None, + }; + // PNG first, sidecar second: a crash between the two leaves an orphaned + // PNG (invisible to the list), never a sidecar pointing at nothing. + std::fs::write(dir.join(&stored_file_name), bytes) + .map_err(|e| format!("failed to write archived card: {e}"))?; + let meta_json = serde_json::to_string_pretty(&meta) + .map_err(|e| format!("failed to serialize card metadata: {e}"))?; + std::fs::write(dir.join(format!("{stem}.json")), meta_json) + .map_err(|e| format!("failed to write card metadata: {e}"))?; + // Thumb last and best-effort: the gallery grid falls back to lazy + // full-card loading for a card whose thumb is missing. + if let Ok(thumb) = encode_card_thumb(bytes) { + let _ = std::fs::write(dir.join(format!("{stem}.thumb.jpg")), thumb); + } + Ok(meta) +} + +/// Downscale card PNG bytes to a small JPEG for gallery grids. The full card +/// is ~1500x2250 PNG (megabytes); shipping that per card over IPC just to +/// draw a grid tile is waste. +fn encode_card_thumb(bytes: &[u8]) -> Result, String> { + const THUMB_WIDTH: u32 = 300; + let img = image::load_from_memory(bytes).map_err(|e| format!("thumb decode: {e}"))?; + let scale = THUMB_WIDTH as f64 / img.width() as f64; + let thumb = img.resize( + THUMB_WIDTH, + (img.height() as f64 * scale).round().max(1.0) as u32, + image::imageops::FilterType::Triangle, + ); + let mut out = Vec::new(); + // JPEG has no alpha; cards are opaque, so flatten unconditionally. + let rgb = image::DynamicImage::ImageRgb8(thumb.to_rgb8()); + rgb.write_to( + &mut std::io::Cursor::new(&mut out), + image::ImageFormat::Jpeg, + ) + .map_err(|e| format!("thumb encode: {e}"))?; + Ok(out) +} + +/// Reject any archive file name that could escape the cards dir or name a +/// non-archive file. Archive names are generated by `archive_minted_card` +/// (slug + UUID), so a strict shape check loses nothing legitimate. +fn validate_archive_file_name(stored_file_name: &str) -> Result<(), String> { + let valid = stored_file_name.ends_with(".agent.png") + && !stored_file_name.contains(['/', '\\']) + && !stored_file_name.contains(".."); + if !valid { + return Err("Invalid archived card file name.".to_string()); + } + Ok(()) +} + +/// List all archived cards, newest first. +#[tauri::command] +pub fn list_agent_cards(app: AppHandle) -> Result, String> { + let dir = cards_dir(&app)?; + let entries = std::fs::read_dir(&dir).map_err(|e| format!("failed to read cards dir: {e}"))?; + let mut cards = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(meta) = serde_json::from_str::(&content) else { + // A malformed sidecar hides one card, never the archive. + eprintln!( + "buzz-desktop: card-archive: skipping malformed sidecar {}", + path.display() + ); + continue; + }; + let mut meta = meta; + if validate_archive_file_name(&meta.stored_file_name).is_ok() + && dir.join(&meta.stored_file_name).is_file() + { + // Attach the pre-rendered grid thumb when present (best-effort). + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + meta.thumb_jpeg_base64 = std::fs::read(dir.join(format!("{stem}.thumb.jpg"))) + .ok() + .map(|b| STANDARD.encode(&b)); + } + cards.push(meta); + } + } + // ISO-8601 sorts lexicographically; newest first. + cards.sort_by(|a, b| b.minted_at.cmp(&a.minted_at)); + Ok(cards) +} + +/// Load one archived card's PNG bytes as base64, keyed by its stored file +/// name (as returned by `list_agent_cards`). +#[tauri::command] +pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result { + validate_archive_file_name(&stored_file_name)?; + let bytes = std::fs::read(cards_dir(&app)?.join(&stored_file_name)) + .map_err(|e| format!("failed to read archived card: {e}"))?; + Ok(STANDARD.encode(&bytes)) +} + +#[cfg(test)] +mod tests { + use super::validate_archive_file_name; + use super::{ArchivedCardMeta, MemoryLevel}; + + #[test] + fn archive_file_name_validation_rejects_escapes() { + assert!(validate_archive_file_name("eva-1234.agent.png").is_ok()); + for bad in [ + "../escape.agent.png", + "sub/dir.agent.png", + "sub\\dir.agent.png", + "not-a-card.png", + "plain.json", + "", + ] { + assert!( + validate_archive_file_name(bad).is_err(), + "expected rejection: {bad:?}" + ); + } + } + + #[test] + fn archived_sidecar_without_memory_level_defaults_to_none() { + // Every mint before the memory option existed embedded + // MemoryLevel::None structurally, so old sidecars (no memoryLevel + // field) must deserialize to None — the gallery's disclosure depends + // on this being honest. + let legacy = r#"{ + "storedFileName": "eva-1234.agent.png", + "fileName": "eva.agent.png", + "agentId": "abc", + "agentName": "Eva", + "designerNotes": "", + "locked": false, + "mintedAt": "2026-07-28T00:00:00Z" + }"#; + let meta: ArchivedCardMeta = serde_json::from_str(legacy).unwrap(); + assert_eq!(meta.memory_level, MemoryLevel::None); + + let with_level = legacy.replace( + "\"locked\": false,", + "\"locked\": false, \"memoryLevel\": \"everything\",", + ); + let meta: ArchivedCardMeta = serde_json::from_str(&with_level).unwrap(); + assert_eq!(meta.memory_level, MemoryLevel::Everything); + } +} diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs index 407ab44974..617c59d6d0 100644 --- a/desktop/src-tauri/src/commands/personas/card/tests.rs +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -4,24 +4,6 @@ use super::*; use std::collections::BTreeMap; -#[test] -fn archive_file_name_validation_rejects_escapes() { - assert!(validate_archive_file_name("eva-1234.agent.png").is_ok()); - for bad in [ - "../escape.agent.png", - "sub/dir.agent.png", - "sub\\dir.agent.png", - "not-a-card.png", - "plain.json", - "", - ] { - assert!( - validate_archive_file_name(bad).is_err(), - "expected rejection: {bad:?}" - ); - } -} - #[test] fn card_template_decodes_with_expected_shape() { // The embedded template is generation input only, but a corrupt or @@ -358,31 +340,6 @@ fn save_rejects_plain_png_without_snapshot_chunk() { assert!(decode_snapshot_png(&png).is_err()); } -#[test] -fn archived_sidecar_without_memory_level_defaults_to_none() { - // Every mint before the memory option existed embedded MemoryLevel::None - // structurally, so old sidecars (no memoryLevel field) must deserialize - // to None — the gallery's disclosure depends on this being honest. - let legacy = r#"{ - "storedFileName": "eva-1234.agent.png", - "fileName": "eva.agent.png", - "agentId": "abc", - "agentName": "Eva", - "designerNotes": "", - "locked": false, - "mintedAt": "2026-07-28T00:00:00Z" - }"#; - let meta: ArchivedCardMeta = serde_json::from_str(legacy).unwrap(); - assert_eq!(meta.memory_level, MemoryLevel::None); - - let with_level = legacy.replace( - "\"locked\": false,", - "\"locked\": false, \"memoryLevel\": \"everything\",", - ); - let meta: ArchivedCardMeta = serde_json::from_str(&with_level).unwrap(); - assert_eq!(meta.memory_level, MemoryLevel::Everything); -} - #[test] fn minted_card_serializes_memory_level_snake_case_value() { // The TS layer narrows on the exact wire strings "none"/"core"/ diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index fd7215b5a5..d969ac5866 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -127,9 +127,19 @@ pub(crate) async fn connect_audio_relay( .send(WsMsg::Text(auth_msg.to_string().into())) .await .map_err(|e| format!("send auth: {e}"))?; - // Sign → auth-send complete: the bounded lease has served its purpose. - // Drop it before awaiting `joined` so it does not span the wait (spec - // L4505-4508); the session capability below carries authority forward. + // Register the durable session capability WHILE the auth lease is still + // held, so it stamps the generation that lease was admitted under — the + // generation the NIP-42 auth was signed under. Registering after the + // `joined` await (where the lease is already dropped) would re-read a + // generation that a concurrent transition may have bumped, stamping the + // winning generation onto authority derived from the losing identity — a + // barrier bypass. Its cancellation token is the C5 barrier's teardown + // handle; the send task validates it before every frame. + let cancel = CancellationToken::new(); + let session = crate::owner_identity_egress::register_owner_session(&auth_lease, cancel.clone()); + // Sign → auth-send → register complete: the bounded lease has served its + // purpose. Drop it before awaiting `joined` so it does not span the wait + // (spec L4505-4508); the session capability carries authority forward. drop(auth_lease); let initial_peers: Vec<(u8, String)> = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { @@ -171,14 +181,7 @@ pub(crate) async fn connect_audio_relay( .map_err(|_| "timeout waiting for joined from relay".to_string())? .map_err(|e: String| e)?; - let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); - // Register the durable session capability, stamped with the current - // identity-persistence generation and carrying THIS connection's - // cancellation token as its revocation handle. The send task validates it - // before every frame; the C5 coordinator barrier invokes the registered - // token to tear the socket down when an identity transition supersedes it. - let session = crate::owner_identity_egress::register_owner_session(cancel.clone()); let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::>(50); let output_device_name = state .huddle_audio diff --git a/desktop/src-tauri/src/owner_identity_egress/durable.rs b/desktop/src-tauri/src/owner_identity_egress/durable.rs new file mode 100644 index 0000000000..049e41c715 --- /dev/null +++ b/desktop/src-tauri/src/owner_identity_egress/durable.rs @@ -0,0 +1,494 @@ +//! Durable owner-identity capabilities (C2 / P30-C1). +//! +//! Split from the parent module (which owns the bounded per-send lease, the +//! registry, and the coordinator drain/latch) so each file stays within the +//! desktop file-size discipline. This half is the DURABLE capability substrate: +//! authority that outlives the bounded lease that derived it (long-lived +//! sessions, pre-minted bearers). It reads the parent's registry generation +//! and admission state through `super::` — the two halves share ONE registry. + +use std::collections::HashMap; +use std::sync::atomic::Ordering; +use std::sync::{LazyLock, Mutex}; + +use tokio_util::sync::CancellationToken; + +use super::{lock_inner, IdentityPersistenceState, OwnerIdentityEgressLease, REGISTRY}; + +/// A generation-stamped, registry-tracked owner-identity capability whose +/// authority OUTLIVES the bounded lease that derived it. +/// +/// The bounded [`OwnerIdentityEgressLease`] above covers authority derived and +/// consumed inside one sign → auth → transmit window. But two owner-key +/// operations mint authority that a LATER, separate operation exercises: +/// +/// - **Sessions** ([`SessionPolicy`]) — the huddle audio socket authenticates +/// ONCE with a NIP-42 event, then a long-lived task emits unsigned frames +/// over the established peer indefinitely; the frontend relay WS is the same +/// shape (`create_auth_event` signs the handshake, later frames ride the +/// connection). Cloning the owner keys before the transition cannot express +/// that a later unsigned frame inherits pre-transition authority. +/// - **Bearers** ([`BearerPolicy`]) — `mint_media_get_auth` / +/// `sign_blossom_upload_auth` sign a server-scoped Blossom header that +/// callers attach at LATER HTTP transmissions, up to ten minutes after +/// issuance. The bearer TTL is wider than a transition, so TTL is not a +/// substitute for invalidation. +/// +/// Every durable capability is REGISTERED in this same egress registry and +/// STAMPED with the identity-persistence generation current at issuance +/// (issuance itself runs under a bounded lease — the signing that derives the +/// capability is an ordinary leased operation). Every transmission over it +/// validates, immediately before the irreversible boundary (frame send / +/// header attach), BOTH current egress admission AND +/// `capability_generation == current identity-persistence generation` +/// ([`OwnerIdentityCapability::admit_exercise`]) — a stale capability is +/// refused with zero bytes sent. The type is the only carrier of that stamped +/// generation, so no exercise site can skip the check (there is no raw handle +/// to exercise). +/// +/// The registry additionally holds each capability's REVOCATION HANDLE (the +/// session cancellation token; the bearer's registry id for invalidation), so +/// the C5 coordinator barrier only *invokes* what C2 already registered — it +/// never retrofits the registry schema. Registering the teardown authority is +/// substrate (C2); invoking it at the transition barrier is C5. +#[derive(Debug)] +#[must_use = "a durable owner-identity capability must be validated \ + (admit_exercise) immediately before every transmission over it"] +pub struct OwnerIdentityCapability { + /// Registry id — the key under which this capability's revocation handle + /// lives, so the C5 barrier can invalidate it by generation. + id: u64, + /// The identity-persistence generation current when this capability was + /// issued. Exercise is refused once the registry generation advances past + /// it. + generation: u64, + _policy: std::marker::PhantomData

, +} + +/// A policy distinguishing the durable capability kinds. Zero-sized markers — +/// the shared behavior (registration, generation-stamp, exercise validation, +/// revocation-handle storage) lives on [`OwnerIdentityCapability`]; the policy +/// only names the kind so the constructor inventory and the registry's +/// per-kind revocation are type-directed. +pub trait CapabilityPolicy: std::fmt::Debug + private::Sealed { + /// A human label for diagnostics and the closed-world inventory. + const KIND: &'static str; +} + +/// Authenticated-connection authority (huddle audio socket, frontend relay +/// WS). The registered revocation handle is a [`CancellationToken`] whose +/// cancellation tears the connection down. +#[derive(Debug)] +pub struct SessionPolicy; +/// Pre-minted bearer authority (Blossom `t=get`/`t=upload` headers). The +/// registered revocation handle is the registry id; invalidation removes the +/// entry so a later attach fails admission. +#[derive(Debug)] +pub struct BearerPolicy; + +impl CapabilityPolicy for SessionPolicy { + const KIND: &'static str = "session"; +} +impl CapabilityPolicy for BearerPolicy { + const KIND: &'static str = "bearer"; +} + +mod private { + pub trait Sealed {} + impl Sealed for super::SessionPolicy {} + impl Sealed for super::BearerPolicy {} +} + +/// The revocation authority for one registered durable capability, invoked by +/// the C5 coordinator barrier to tear down old-generation authority before the +/// journal write + durable dispatch. +enum RevocationHandle { + /// Cancel the connection (huddle socket / frontend WS teardown path). + Session(CancellationToken), + /// Bearer invalidation is by-entry: removing the registry entry is the + /// invalidation, so no side-effecting handle is needed. The variant exists + /// so the registry records the capability's kind for the per-kind barrier. + Bearer, +} + +/// Registry of live durable capabilities, keyed by capability id, under its +/// own [`Mutex`] (`DURABLE`) — separate from the bounded-lease state +/// (`REGISTRY.inner`). Registration is therefore NOT lock-linearized against +/// generation bumps: a bump can slip between a lease's admission and its +/// capability's registration. The safety guarantee is not lock ordering but +/// EXERCISE-TIME validation — each capability stamps its issuing lease's +/// generation and [`OwnerIdentityCapability::admit_exercise`] refuses once the +/// current generation advances past it, so a capability registered across a +/// bump fails closed on first use. +#[derive(Default)] +struct DurableRegistry { + /// Next capability id. Monotonic; ids are never reused. + next_id: u64, + /// Live capabilities: id → (issued generation, revocation handle). + entries: HashMap, +} + +static DURABLE: LazyLock> = + LazyLock::new(|| Mutex::new(DurableRegistry::default())); + +fn lock_durable() -> std::sync::MutexGuard<'static, DurableRegistry> { + DURABLE.lock().unwrap_or_else(|p| p.into_inner()) +} + +/// Clear all registered durable capabilities. Called by the parent's +/// `reset_registry_for_test` so a test starts from an empty registry. `next_id` +/// is monotonic and never resets, mirroring the generation. +#[cfg(test)] +pub(super) fn reset_for_test() { + lock_durable().entries.clear(); +} + +impl OwnerIdentityCapability

{ + /// The identity-persistence generation this capability was issued under. + // Consumed by C5 (barrier introspection) and the C2 tests; remove allow + // when a production caller reads it. + #[allow(dead_code)] + pub fn generation(&self) -> u64 { + self.generation + } + + /// Validate this capability immediately before an irreversible + /// transmission over it (frame send / header attach). Succeeds only when + /// egress admission is `Live` AND the stamped generation still equals the + /// current identity-persistence generation. A stale or drained capability + /// is refused so the caller sends zero bytes. + /// + /// This is a cheap compare, not a re-authentication: the durable + /// capability already proved identity at issuance; exercise only confirms + /// that proof has not been superseded by a transition. + pub fn admit_exercise(&self) -> Result<(), String> { + let inner = lock_inner(); + if inner.state != IdentityPersistenceState::Live { + return Err(format!( + "owner-identity {} capability cannot transmit: egress is {:?}", + P::KIND, + inner.state + )); + } + let current = REGISTRY.generation.load(Ordering::Acquire); + if self.generation != current { + return Err(format!( + "owner-identity {} capability is stale (issued under generation \ + {}, current {}); the identity transitioned and this authority \ + was revoked", + P::KIND, + self.generation, + current + )); + } + Ok(()) + } +} + +impl Drop for OwnerIdentityCapability

{ + fn drop(&mut self) { + // Dropping the capability handle deregisters it — a session whose task + // has ended or a bearer no longer attachable must not linger as a + // revocation target. Barrier invalidation (C5) also removes entries; + // the id is unique and never reused, so a double-remove is a no-op. + lock_durable().entries.remove(&self.id); + } +} + +/// Issue a durable owner-identity session capability, registering its +/// cancellation token so the C5 barrier can tear the connection down. +/// +/// Takes the issuing [`OwnerIdentityEgressLease`] as a witness and stamps the +/// generation THAT LEASE was admitted under — never a freshly re-read one. A +/// bump can slip between admission and registration (`begin_egress_drain` +/// advances the generation immediately, before in-flight leases drain), so +/// re-reading here would stamp the winning generation onto authority derived +/// from the losing identity — a barrier bypass. Stamping the lease generation +/// fails closed instead: if a bump slipped in, the stamp is already stale and +/// the first `admit_exercise` refuses. +/// +/// Call this AFTER the authenticating sign/auth has completed under the lease +/// (issuance is an ordinary leased operation, spec L4569–4570) and the +/// connection is established. The lease may already be dropped — its generation +/// is copied here — but taking it by reference makes leased issuance +/// compile-enforced, not conventional. +pub fn register_owner_session( + lease: &OwnerIdentityEgressLease, + cancel: CancellationToken, +) -> OwnerIdentityCapability { + register_durable(lease.generation(), RevocationHandle::Session(cancel)) +} + +/// Issue a durable owner-identity bearer capability, registering it so the C5 +/// barrier can invalidate it by generation. +/// +/// Stamps the issuing [`OwnerIdentityEgressLease`]'s generation, not a re-read +/// one — see [`register_owner_session`] for why re-reading is a barrier bypass. +/// +/// Call this immediately after minting the bearer header under the lease. Every +/// attach site validates the returned capability via +/// [`OwnerIdentityCapability::admit_exercise`] before the HTTP dispatch. +pub fn register_owner_bearer( + lease: &OwnerIdentityEgressLease, +) -> OwnerIdentityCapability { + register_durable(lease.generation(), RevocationHandle::Bearer) +} + +/// Register a durable capability stamped with `generation` and carrying +/// `handle` as its revocation authority. Shared by both constructors so the +/// id allocation and registry insert live in one place. +fn register_durable( + generation: u64, + handle: RevocationHandle, +) -> OwnerIdentityCapability

{ + let mut durable = lock_durable(); + let id = durable.next_id; + durable.next_id += 1; + durable.entries.insert(id, (generation, handle)); + OwnerIdentityCapability { + id, + generation, + _policy: std::marker::PhantomData, + } +} + +/// Revoke every durable capability issued under a generation older than +/// `winning_generation`: cancel old sessions (invoking each registered +/// [`CancellationToken`]) and drop old bearers (removing their entries so a +/// later attach fails [`OwnerIdentityCapability::admit_exercise`]). Returns the +/// number of capabilities revoked. +/// +/// The C5 coordinator barrier calls this after +/// [`begin_egress_drain`]/[`await_egress_drain`] and BEFORE the journal write + +/// durable B dispatch, so no old-generation session or bearer can transmit +/// across the durable boundary. It only *invokes* the handles C2 registered. +// Consumed by C5 (P25/P28 coordinator barrier); remove allow when C5 lands. +#[allow(dead_code)] +pub fn revoke_durable_capabilities_before(winning_generation: u64) -> usize { + let mut durable = lock_durable(); + let stale: Vec = durable + .entries + .iter() + .filter(|(_, (gen, _))| *gen < winning_generation) + .map(|(id, _)| *id) + .collect(); + for id in &stale { + if let Some((_, RevocationHandle::Session(cancel))) = durable.entries.remove(id) { + cancel.cancel(); + } + } + stale.len() +} + +/// The number of live durable capabilities registered. Used by the +/// registration-completeness assertion (C2) to prove every issued +/// session/bearer is present with a revocation handle the C5 barrier can +/// invoke. +// Consumed by C5 and the C2 registration-completeness tests; remove allow when +// a production caller lands. +#[allow(dead_code)] +pub fn live_durable_capability_count() -> usize { + lock_durable().entries.len() +} + +#[cfg(test)] +mod tests { + use super::super::{ + begin_egress_drain, current_identity_persistence_generation, latch_identity_indeterminate, + resume_egress_live, + }; + use super::*; + + fn guard() -> std::sync::MutexGuard<'static, ()> { + let g = super::super::EGRESS_REGISTRY_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + super::super::reset_registry_for_test(); + g + } + + /// A bare owner-identity lease admitted under the current generation, for + /// registering durable capabilities in tests. + fn lease() -> OwnerIdentityEgressLease { + super::super::admit_owner_identity_after_wait().expect("test lease admits when live") + } + + #[test] + fn durable_capabilities_stamp_the_current_generation() { + let _g = guard(); + let session = register_owner_session(&lease(), CancellationToken::new()); + let bearer = register_owner_bearer(&lease()); + let current = current_identity_persistence_generation(); + assert_eq!(session.generation(), current); + assert_eq!(bearer.generation(), current); + } + + #[test] + fn durable_capability_exercise_admits_when_live_and_current() { + let _g = guard(); + let session = register_owner_session(&lease(), CancellationToken::new()); + let bearer = register_owner_bearer(&lease()); + assert!(session.admit_exercise().is_ok()); + assert!(bearer.admit_exercise().is_ok()); + } + + // §7 revocation schedule (session): a session issued under A cannot + // transmit after the generation advances to B — the frame send is refused + // with zero bytes. Drives the registry generation directly (C1 drain-test + // pattern); no production transition driver exists until C5. + #[test] + fn stale_session_exercise_refuses_after_a_generation_bump() { + let _g = guard(); + let session = register_owner_session(&lease(), CancellationToken::new()); + // A transition drains then resumes at the winning generation. + begin_egress_drain().unwrap(); + resume_egress_live(); + assert!( + session.admit_exercise().is_err(), + "a session stamped under A must refuse to transmit after B wins" + ); + } + + // §7 revocation schedule (bearer): same stale-generation control for the + // pre-minted bearer attach path. + #[test] + fn stale_bearer_exercise_refuses_after_a_generation_bump() { + let _g = guard(); + let bearer = register_owner_bearer(&lease()); + begin_egress_drain().unwrap(); + resume_egress_live(); + assert!( + bearer.admit_exercise().is_err(), + "a bearer minted under A must refuse to attach after B wins" + ); + } + + // F1 regression: a capability REGISTERED after a bump but derived from a + // lease admitted BEFORE it stamps the lease's (losing) generation, not the + // winning one — so it fails closed. This is the barrier-bypass control: + // before the stamp-from-lease fix, registration re-read the winning + // generation and the capability survived the barrier and admitted under B. + #[test] + fn capability_registered_across_a_bump_stamps_the_losing_generation() { + let _g = guard(); + // Lease admitted under the losing generation A. + let lease = lease(); + // A transition begins and bumps the generation to B before the lease + // drains (begin_egress_drain advances immediately). + let winning = begin_egress_drain().unwrap(); + // Registration happens now, under the pre-bump lease. + let bearer = register_owner_bearer(&lease); + assert_eq!( + bearer.generation(), + winning - 1, + "the capability stamps the lease's losing generation, not the winning one" + ); + // The barrier revokes everything older than the winner — this bearer + // is caught, not bypassed. + assert_eq!( + revoke_durable_capabilities_before(winning), + 1, + "the across-a-bump capability is revoked by the barrier" + ); + // And even had it survived, exercise refuses once the state is Live + // again: its stamp is stale. + resume_egress_live(); + drop(lease); + assert!( + bearer.admit_exercise().is_err(), + "a capability derived from the losing identity must refuse to transmit" + ); + } + + // Stale-capability ZERO-BYTES control (Paul's condition a): exercise + // validation fails BEFORE any transmission, for both durable kinds, under + // both a generation mismatch and the drain/latch state — so no site can + // send a byte on stale authority. + #[test] + fn durable_exercise_refuses_while_draining_and_while_latched() { + let _g = guard(); + let session = register_owner_session(&lease(), CancellationToken::new()); + let bearer = register_owner_bearer(&lease()); + begin_egress_drain().unwrap(); + assert!( + session.admit_exercise().is_err(), + "draining refuses session" + ); + assert!(bearer.admit_exercise().is_err(), "draining refuses bearer"); + latch_identity_indeterminate(); + assert!(session.admit_exercise().is_err(), "latch refuses session"); + assert!(bearer.admit_exercise().is_err(), "latch refuses bearer"); + } + + // The C5 barrier cancels old-generation sessions and drops old bearers, + // and does NOT touch capabilities issued at the winning generation. + #[test] + fn barrier_revokes_old_generation_capabilities_only() { + let _g = guard(); + let old_session_cancel = CancellationToken::new(); + let old_session = register_owner_session(&lease(), old_session_cancel.clone()); + let _old_bearer = register_owner_bearer(&lease()); + assert_eq!(live_durable_capability_count(), 2); + + // Transition to B. + let winning = begin_egress_drain().unwrap(); + resume_egress_live(); + // A capability issued at the winning generation survives the barrier. + let new_session = register_owner_session(&lease(), CancellationToken::new()); + + let revoked = revoke_durable_capabilities_before(winning); + assert_eq!(revoked, 2, "both old-generation capabilities revoked"); + assert!( + old_session_cancel.is_cancelled(), + "the old session's registered token was invoked" + ); + assert_eq!( + live_durable_capability_count(), + 1, + "only the winning-generation capability remains" + ); + assert!( + new_session.admit_exercise().is_ok(), + "the winning-generation session still transmits" + ); + // old_session is now deregistered by the barrier; dropping it is a + // no-op remove. + drop(old_session); + } + + // Registration-completeness assertion (Paul's condition b): every issued + // durable capability is present in the registry with a revocation handle + // the C5 barrier can invoke, and dropping the handle deregisters it so a + // dead session/bearer is never a stale revocation target. + #[test] + fn issued_capabilities_are_registered_and_deregister_on_drop() { + let _g = guard(); + assert_eq!(live_durable_capability_count(), 0); + let session = register_owner_session(&lease(), CancellationToken::new()); + assert_eq!(live_durable_capability_count(), 1); + let bearer = register_owner_bearer(&lease()); + assert_eq!(live_durable_capability_count(), 2); + drop(session); + assert_eq!(live_durable_capability_count(), 1, "session deregistered"); + drop(bearer); + assert_eq!(live_durable_capability_count(), 0, "bearer deregistered"); + } + + // A no-transition control: with no generation bump, a registered + // capability keeps transmitting and the barrier revokes nothing. + #[test] + fn no_transition_leaves_durable_capabilities_intact() { + let _g = guard(); + let session = register_owner_session(&lease(), CancellationToken::new()); + let current = current_identity_persistence_generation(); + assert_eq!( + revoke_durable_capabilities_before(current), + 0, + "nothing older than the current generation to revoke" + ); + assert!( + session.admit_exercise().is_ok(), + "with no transition the session still transmits" + ); + } +} diff --git a/desktop/src-tauri/src/owner_identity_egress.rs b/desktop/src-tauri/src/owner_identity_egress/mod.rs similarity index 64% rename from desktop/src-tauri/src/owner_identity_egress.rs rename to desktop/src-tauri/src/owner_identity_egress/mod.rs index 75dbf9cac0..a890e458ce 100644 --- a/desktop/src-tauri/src/owner_identity_egress.rs +++ b/desktop/src-tauri/src/owner_identity_egress/mod.rs @@ -69,6 +69,9 @@ //! - `managed_agents::persona_events`, `commands::personas::sharing`, //! `commands::channels` (×3), and the huddle STT task (per-send inline //! owner lease). +//! - `commands::identity::create_auth_event` — the relay-WS reconnection +//! NIP-42 handshake (per-send lease; frontend session registration defers to +//! C6/C7 with the frontend identity store). //! - **Durable bearers** — `commands::media::mint_media_get_auth` (Blossom //! `t=get`) and the `do_upload` `t=upload` mint sign under a bounded lease //! and register an [`OwnerIdentityCapability`]; the four @@ -87,12 +90,10 @@ //! `ManagedAgentKeyed` sites). //! 5. the four `project_git_workflow` PR-status/merge sends (items 5–8). -use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{LazyLock, Mutex}; use tokio::sync::Notify; -use tokio_util::sync::CancellationToken; /// Admission state of owner-identity egress — the third recovery state /// (`Indeterminate`) extends the existing `AppState::identity_lost` / @@ -199,8 +200,8 @@ pub struct OwnerIdentityEgressLease { impl OwnerIdentityEgressLease { /// The identity-persistence generation this lease was admitted under. - // Consumed by C5 (P25/P28 coordinator); remove allow when C5 lands. - #[allow(dead_code)] + /// Stamped onto durable capabilities issued under this lease so they fail + /// closed if a transition bumped the generation after admission. pub fn generation(&self) -> u64 { self.generation } @@ -469,259 +470,19 @@ fn admit_managed_agent_after_wait() -> Result { } } -// --------------------------------------------------------------------------- -// Durable owner-identity capabilities (C2 / P30-C1) -// --------------------------------------------------------------------------- - -/// A generation-stamped, registry-tracked owner-identity capability whose -/// authority OUTLIVES the bounded lease that derived it. -/// -/// The bounded [`OwnerIdentityEgressLease`] above covers authority derived and -/// consumed inside one sign → auth → transmit window. But two owner-key -/// operations mint authority that a LATER, separate operation exercises: -/// -/// - **Sessions** ([`SessionPolicy`]) — the huddle audio socket authenticates -/// ONCE with a NIP-42 event, then a long-lived task emits unsigned frames -/// over the established peer indefinitely; the frontend relay WS is the same -/// shape (`create_auth_event` signs the handshake, later frames ride the -/// connection). Cloning the owner keys before the transition cannot express -/// that a later unsigned frame inherits pre-transition authority. -/// - **Bearers** ([`BearerPolicy`]) — `mint_media_get_auth` / -/// `sign_blossom_upload_auth` sign a server-scoped Blossom header that -/// callers attach at LATER HTTP transmissions, up to ten minutes after -/// issuance. The bearer TTL is wider than a transition, so TTL is not a -/// substitute for invalidation. -/// -/// Every durable capability is REGISTERED in this same egress registry and -/// STAMPED with the identity-persistence generation current at issuance -/// (issuance itself runs under a bounded lease — the signing that derives the -/// capability is an ordinary leased operation). Every transmission over it -/// validates, immediately before the irreversible boundary (frame send / -/// header attach), BOTH current egress admission AND -/// `capability_generation == current identity-persistence generation` -/// ([`OwnerIdentityCapability::admit_exercise`]) — a stale capability is -/// refused with zero bytes sent. The type is the only carrier of that stamped -/// generation, so no exercise site can skip the check (there is no raw handle -/// to exercise). -/// -/// The registry additionally holds each capability's REVOCATION HANDLE (the -/// session cancellation token; the bearer's registry id for invalidation), so -/// the C5 coordinator barrier only *invokes* what C2 already registered — it -/// never retrofits the registry schema. Registering the teardown authority is -/// substrate (C2); invoking it at the transition barrier is C5. -#[derive(Debug)] -#[must_use = "a durable owner-identity capability must be validated \ - (admit_exercise) immediately before every transmission over it"] -pub struct OwnerIdentityCapability { - /// Registry id — the key under which this capability's revocation handle - /// lives, so the C5 barrier can invalidate it by generation. - id: u64, - /// The identity-persistence generation current when this capability was - /// issued. Exercise is refused once the registry generation advances past - /// it. - generation: u64, - _policy: std::marker::PhantomData

, -} - -/// A policy distinguishing the durable capability kinds. Zero-sized markers — -/// the shared behavior (registration, generation-stamp, exercise validation, -/// revocation-handle storage) lives on [`OwnerIdentityCapability`]; the policy -/// only names the kind so the constructor inventory and the registry's -/// per-kind revocation are type-directed. -pub trait CapabilityPolicy: std::fmt::Debug + private::Sealed { - /// A human label for diagnostics and the closed-world inventory. - const KIND: &'static str; -} - -/// Authenticated-connection authority (huddle audio socket, frontend relay -/// WS). The registered revocation handle is a [`CancellationToken`] whose -/// cancellation tears the connection down. -#[derive(Debug)] -pub struct SessionPolicy; -/// Pre-minted bearer authority (Blossom `t=get`/`t=upload` headers). The -/// registered revocation handle is the registry id; invalidation removes the -/// entry so a later attach fails admission. -#[derive(Debug)] -pub struct BearerPolicy; - -impl CapabilityPolicy for SessionPolicy { - const KIND: &'static str = "session"; -} -impl CapabilityPolicy for BearerPolicy { - const KIND: &'static str = "bearer"; -} - -mod private { - pub trait Sealed {} - impl Sealed for super::SessionPolicy {} - impl Sealed for super::BearerPolicy {} -} - -/// The revocation authority for one registered durable capability, invoked by -/// the C5 coordinator barrier to tear down old-generation authority before the -/// journal write + durable dispatch. -enum RevocationHandle { - /// Cancel the connection (huddle socket / frontend WS teardown path). - Session(CancellationToken), - /// Bearer invalidation is by-entry: removing the registry entry is the - /// invalidation, so no side-effecting handle is needed. The variant exists - /// so the registry records the capability's kind for the per-kind barrier. - Bearer, -} - -/// Registry of live durable capabilities, keyed by capability id. Guarded by -/// the same lock as the bounded-lease state so registration, exercise -/// validation, and the C5 barrier all linearize against generation bumps. -#[derive(Default)] -struct DurableRegistry { - /// Next capability id. Monotonic; ids are never reused. - next_id: u64, - /// Live capabilities: id → (issued generation, revocation handle). - entries: HashMap, -} - -static DURABLE: LazyLock> = - LazyLock::new(|| Mutex::new(DurableRegistry::default())); - -fn lock_durable() -> std::sync::MutexGuard<'static, DurableRegistry> { - DURABLE.lock().unwrap_or_else(|p| p.into_inner()) -} - -impl OwnerIdentityCapability

{ - /// The identity-persistence generation this capability was issued under. - // Consumed by C5 (barrier introspection) and the C2 tests; remove allow - // when a production caller reads it. - #[allow(dead_code)] - pub fn generation(&self) -> u64 { - self.generation - } - - /// Validate this capability immediately before an irreversible - /// transmission over it (frame send / header attach). Succeeds only when - /// egress admission is `Live` AND the stamped generation still equals the - /// current identity-persistence generation. A stale or drained capability - /// is refused so the caller sends zero bytes. - /// - /// This is a cheap compare, not a re-authentication: the durable - /// capability already proved identity at issuance; exercise only confirms - /// that proof has not been superseded by a transition. - pub fn admit_exercise(&self) -> Result<(), String> { - let inner = lock_inner(); - if inner.state != IdentityPersistenceState::Live { - return Err(format!( - "owner-identity {} capability cannot transmit: egress is {:?}", - P::KIND, - inner.state - )); - } - let current = REGISTRY.generation.load(Ordering::Acquire); - if self.generation != current { - return Err(format!( - "owner-identity {} capability is stale (issued under generation \ - {}, current {}); the identity transitioned and this authority \ - was revoked", - P::KIND, - self.generation, - current - )); - } - Ok(()) - } -} - -impl Drop for OwnerIdentityCapability

{ - fn drop(&mut self) { - // Dropping the capability handle deregisters it — a session whose task - // has ended or a bearer no longer attachable must not linger as a - // revocation target. Barrier invalidation (C5) also removes entries; - // the id is unique and never reused, so a double-remove is a no-op. - lock_durable().entries.remove(&self.id); - } -} - -/// Issue a durable owner-identity session capability, registering its -/// cancellation token so the C5 barrier can tear the connection down. -/// -/// Call this AFTER the authenticating sign/auth has completed under a bounded -/// lease (issuance is an ordinary leased operation, spec L4569–4570) and the -/// connection is established. The returned capability stamps the current -/// generation; the connection's send task validates it via -/// [`OwnerIdentityCapability::admit_exercise`] before each frame. -pub fn register_owner_session(cancel: CancellationToken) -> OwnerIdentityCapability { - let generation = REGISTRY.generation.load(Ordering::Acquire); - let mut durable = lock_durable(); - let id = durable.next_id; - durable.next_id += 1; - durable - .entries - .insert(id, (generation, RevocationHandle::Session(cancel))); - OwnerIdentityCapability { - id, - generation, - _policy: std::marker::PhantomData, - } -} - -/// Issue a durable owner-identity bearer capability, registering it so the C5 -/// barrier can invalidate it by generation. -/// -/// Call this immediately after minting the bearer header under a bounded lease. -/// The returned capability stamps the current generation; every attach site -/// validates it via [`OwnerIdentityCapability::admit_exercise`] before the HTTP -/// dispatch. -pub fn register_owner_bearer() -> OwnerIdentityCapability { - let generation = REGISTRY.generation.load(Ordering::Acquire); - let mut durable = lock_durable(); - let id = durable.next_id; - durable.next_id += 1; - durable - .entries - .insert(id, (generation, RevocationHandle::Bearer)); - OwnerIdentityCapability { - id, - generation, - _policy: std::marker::PhantomData, - } -} - -/// Revoke every durable capability issued under a generation older than -/// `winning_generation`: cancel old sessions (invoking each registered -/// [`CancellationToken`]) and drop old bearers (removing their entries so a -/// later attach fails [`OwnerIdentityCapability::admit_exercise`]). Returns the -/// number of capabilities revoked. -/// -/// The C5 coordinator barrier calls this after -/// [`begin_egress_drain`]/[`await_egress_drain`] and BEFORE the journal write + -/// durable B dispatch, so no old-generation session or bearer can transmit -/// across the durable boundary. It only *invokes* the handles C2 registered. -// Consumed by C5 (P25/P28 coordinator barrier); remove allow when C5 lands. -#[allow(dead_code)] -pub fn revoke_durable_capabilities_before(winning_generation: u64) -> usize { - let mut durable = lock_durable(); - let stale: Vec = durable - .entries - .iter() - .filter(|(_, (gen, _))| *gen < winning_generation) - .map(|(id, _)| *id) - .collect(); - for id in &stale { - if let Some((_, RevocationHandle::Session(cancel))) = durable.entries.remove(id) { - cancel.cancel(); - } - } - stale.len() -} - -/// The number of live durable capabilities registered. Used by the -/// registration-completeness assertion (C2) to prove every issued -/// session/bearer is present with a revocation handle the C5 barrier can -/// invoke. -// Consumed by C5 and the C2 registration-completeness tests; remove allow when -// a production caller lands. -#[allow(dead_code)] -pub fn live_durable_capability_count() -> usize { - lock_durable().entries.len() -} +/// Durable owner-identity capabilities (sessions, bearers) — authority that +/// outlives the bounded lease that derived it. Split into a child module for +/// file-size discipline; it shares this module's one registry. +mod durable; +pub use durable::{ + register_owner_bearer, register_owner_session, BearerPolicy, OwnerIdentityCapability, + SessionPolicy, +}; +// The C5 coordinator barrier and its registration-completeness reader; no +// production caller until C5, so the re-exports are unused today. Consumed by +// C5 (P25/P28); remove allow when C5 lands. +#[allow(unused_imports)] +pub use durable::revoke_durable_capabilities_before; /// Process-global mutex serializing tests that mutate the registry's /// process-global state. Any test that admits a lease, drives a drain, or @@ -739,8 +500,7 @@ pub(crate) fn reset_registry_for_test() { inner.state = IdentityPersistenceState::Live; inner.in_flight = 0; drop(inner); - let mut durable = lock_durable(); - durable.entries.clear(); + durable::reset_for_test(); // next_id is monotonic and never resets, mirroring the generation: tests // assert on presence/count and relative generation, never absolute ids. } @@ -1006,150 +766,4 @@ mod tests { ); crate::relay_admission::reset_rate_limit_gate(); } - - // ----------------------------------------------------------------------- - // Durable capability substrate (C2 / P30-C1) - // ----------------------------------------------------------------------- - - #[test] - fn durable_capabilities_stamp_the_current_generation() { - let _g = guard(); - let session = register_owner_session(CancellationToken::new()); - let bearer = register_owner_bearer(); - let current = current_identity_persistence_generation(); - assert_eq!(session.generation(), current); - assert_eq!(bearer.generation(), current); - } - - #[test] - fn durable_capability_exercise_admits_when_live_and_current() { - let _g = guard(); - let session = register_owner_session(CancellationToken::new()); - let bearer = register_owner_bearer(); - assert!(session.admit_exercise().is_ok()); - assert!(bearer.admit_exercise().is_ok()); - } - - // §7 revocation schedule (session): a session issued under A cannot - // transmit after the generation advances to B — the frame send is refused - // with zero bytes. Drives the registry generation directly (C1 drain-test - // pattern); no production transition driver exists until C5. - #[test] - fn stale_session_exercise_refuses_after_a_generation_bump() { - let _g = guard(); - let session = register_owner_session(CancellationToken::new()); - // A transition drains then resumes at the winning generation. - begin_egress_drain().unwrap(); - resume_egress_live(); - assert!( - session.admit_exercise().is_err(), - "a session stamped under A must refuse to transmit after B wins" - ); - } - - // §7 revocation schedule (bearer): same stale-generation control for the - // pre-minted bearer attach path. - #[test] - fn stale_bearer_exercise_refuses_after_a_generation_bump() { - let _g = guard(); - let bearer = register_owner_bearer(); - begin_egress_drain().unwrap(); - resume_egress_live(); - assert!( - bearer.admit_exercise().is_err(), - "a bearer minted under A must refuse to attach after B wins" - ); - } - - // Stale-capability ZERO-BYTES control (Paul's condition a): exercise - // validation fails BEFORE any transmission, for both durable kinds, under - // both a generation mismatch and the drain/latch state — so no site can - // send a byte on stale authority. - #[test] - fn durable_exercise_refuses_while_draining_and_while_latched() { - let _g = guard(); - let session = register_owner_session(CancellationToken::new()); - let bearer = register_owner_bearer(); - begin_egress_drain().unwrap(); - assert!( - session.admit_exercise().is_err(), - "draining refuses session" - ); - assert!(bearer.admit_exercise().is_err(), "draining refuses bearer"); - latch_identity_indeterminate(); - assert!(session.admit_exercise().is_err(), "latch refuses session"); - assert!(bearer.admit_exercise().is_err(), "latch refuses bearer"); - } - - // The C5 barrier cancels old-generation sessions and drops old bearers, - // and does NOT touch capabilities issued at the winning generation. - #[test] - fn barrier_revokes_old_generation_capabilities_only() { - let _g = guard(); - let old_session_cancel = CancellationToken::new(); - let old_session = register_owner_session(old_session_cancel.clone()); - let _old_bearer = register_owner_bearer(); - assert_eq!(live_durable_capability_count(), 2); - - // Transition to B. - let winning = begin_egress_drain().unwrap(); - resume_egress_live(); - // A capability issued at the winning generation survives the barrier. - let new_session = register_owner_session(CancellationToken::new()); - - let revoked = revoke_durable_capabilities_before(winning); - assert_eq!(revoked, 2, "both old-generation capabilities revoked"); - assert!( - old_session_cancel.is_cancelled(), - "the old session's registered token was invoked" - ); - assert_eq!( - live_durable_capability_count(), - 1, - "only the winning-generation capability remains" - ); - assert!( - new_session.admit_exercise().is_ok(), - "the winning-generation session still transmits" - ); - // old_session is now deregistered by the barrier; dropping it is a - // no-op remove. - drop(old_session); - } - - // Registration-completeness assertion (Paul's condition b): every issued - // durable capability is present in the registry with a revocation handle - // the C5 barrier can invoke, and dropping the handle deregisters it so a - // dead session/bearer is never a stale revocation target. - #[test] - fn issued_capabilities_are_registered_and_deregister_on_drop() { - let _g = guard(); - assert_eq!(live_durable_capability_count(), 0); - let session = register_owner_session(CancellationToken::new()); - assert_eq!(live_durable_capability_count(), 1); - let bearer = register_owner_bearer(); - assert_eq!(live_durable_capability_count(), 2); - drop(session); - assert_eq!(live_durable_capability_count(), 1, "session deregistered"); - drop(bearer); - assert_eq!(live_durable_capability_count(), 0, "bearer deregistered"); - } - - // A no-transition control: with no generation bump, a registered - // capability keeps transmitting and the barrier revokes nothing. - #[test] - fn no_transition_leaves_durable_capabilities_intact() { - let _g = guard(); - let session = register_owner_session(CancellationToken::new()); - let current = current_identity_persistence_generation(); - assert_eq!( - revoke_durable_capabilities_before(current), - 0, - "nothing older than the current generation to revoke" - ); - assert!( - session.admit_exercise().is_ok(), - "with no transition the session still transmits" - ); - } } diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index af203532cf..5118bf0801 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -626,380 +626,5 @@ pub async fn submit_signed_event_with_keys( // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::{ - build_profile_event, classify_intercepted_response, effective_agent_relay_url, - extract_retry_in_hint, parse_command_response, relay_http_base_url, - MALFORMED_RESPONSE_MESSAGE, - }; - use serde::Deserialize; - - // ── extract_retry_in_hint ──────────────────────────────────────────────── - - #[test] - fn extracts_hint_from_429_body() { - assert_eq!( - extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded; retry in 4s"}"#), - Some(4) - ); - } - - #[test] - fn extracts_hint_when_no_json_wrapper() { - assert_eq!(extract_retry_in_hint("retry in 30s"), Some(30)); - } - - #[test] - fn returns_none_when_no_hint_present() { - assert_eq!( - extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded"}"#), - None - ); - assert_eq!(extract_retry_in_hint(""), None); - } - - #[test] - fn overlong_digit_string_returns_none() { - // A digit sequence that exceeds u64::MAX cannot be parsed; the function - // must return None (→ caller uses the default) rather than panicking. - assert_eq!( - extract_retry_in_hint("retry in 99999999999999999999999s"), - None - ); - } - - // ── relay_error_message: hint capping ──────────────────────────────────── - // - // Verify that an oversized relay hint is capped in the returned message - // string, not just inside `activate_rate_limit()`. This guarantees every - // consumer — including the TS gate via `applyTauriRateLimitIfNeeded` — - // receives the capped value rather than the raw untrusted relay value. - - #[tokio::test] - async fn oversized_hint_is_capped_in_relay_error_message_string() { - use crate::relay_admission::MAX_HINT_SECONDS; - use std::io::{Read as _, Write as _}; - - // Use a std::net listener on a std::thread — the same pattern as the - // relay_admission loopback tests. This avoids two races that cause CI - // failures with tokio::net + into_std(): - // 1. No request read: the client is still sending when the response - // arrives → hyper `UnexpectedMessage`/`Canceled` under load. - // 2. into_std() leaves the socket in nonblocking mode → write_all - // may return WouldBlock and silently drop the response. - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - - // Serve a 429 with a hint far exceeding MAX_HINT_SECONDS (300). - let oversized = 1_000_000u64; - let body = format!(r#"{{"error":"rate-limited: quota exceeded; retry in {oversized}s"}}"#); - let body_len = body.len(); - std::thread::spawn(move || { - if let Ok((mut stream, _)) = listener.accept() { - // Read the request first so the client finishes sending before - // we write the response — mirrors relay_admission.rs pattern. - let mut buf = [0u8; 4096]; - let _ = stream.read(&mut buf); - let response = format!( - "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n{body}" - ); - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); - } - }); - - let client = reqwest::Client::new(); - let response = client - .get(format!("http://{addr}/")) - .send() - .await - .expect("request must succeed"); - - let msg = super::relay_error_message(response).await; - - // The message must embed the CAPPED hint, not the raw 1 000 000. - assert_eq!( - msg, - format!("relay rate-limited: retry in {MAX_HINT_SECONDS}s"), - "relay_error_message must embed the capped hint, not the raw untrusted value" - ); - assert!( - !msg.contains(&oversized.to_string()), - "raw oversized hint must not appear in the message string" - ); - } - - // ── effective_agent_relay_url: legacy pin ignored ───────────────────────── - - #[test] - fn stored_relay_pin_is_ignored() { - // Zero-touch cutover (#2122): a creation-era per-record relay pin is - // parsed and persisted but never consulted — the workspace relay wins. - assert_eq!( - effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"), - "wss://staging.example.com" - ); - } - - #[test] - fn empty_relay_resolves_to_workspace() { - // A never-set record resolves to the active workspace relay at read-time, - // so a stale stored default can never make it load-bearing. - assert_eq!( - effective_agent_relay_url("", "wss://staging.example.com"), - "wss://staging.example.com" - ); - } - - #[test] - fn whitespace_only_relay_resolves_to_workspace() { - // Whitespace-only behaves identically — no value survives. - assert_eq!( - effective_agent_relay_url(" ", "wss://staging.example.com"), - "wss://staging.example.com" - ); - } - - // ── relay_http_base_url scheme conversion ──────────────────────────────── - - #[test] - fn loopback_ws_localhost_preserves_authority() { - // Tenant host-binding keys off the HTTP Host/authority. The desktop must - // not rewrite localhost to 127.0.0.1, or local dev HTTP calls target a - // different unmapped community than the WebSocket URL. - assert_eq!( - relay_http_base_url("ws://localhost:3000"), - "http://localhost:3000" - ); - } - - #[test] - fn loopback_trailing_slash_removed_authority_preserved() { - assert_eq!( - relay_http_base_url("ws://localhost:3000/"), - "http://localhost:3000" - ); - } - - #[test] - fn remote_wss_host_unchanged() { - assert_eq!( - relay_http_base_url("wss://relay.example.com"), - "https://relay.example.com" - ); - } - - #[test] - fn loopback_ipv4_literal_unchanged() { - assert_eq!( - relay_http_base_url("ws://127.0.0.1:3000"), - "http://127.0.0.1:3000" - ); - } - - #[test] - fn localhost_substring_host_unchanged() { - assert_eq!( - relay_http_base_url("ws://localhost.evil.com:3000"), - "http://localhost.evil.com:3000" - ); - } - - #[test] - fn loopback_wss_localhost_preserves_authority() { - assert_eq!( - relay_http_base_url("wss://localhost:3000"), - "https://localhost:3000" - ); - } - - // ── classify_intercepted_response ──────────────────────────────────────── - - #[test] - fn intercepted_cloudflare_host_returns_some() { - let result = classify_intercepted_response("sqprod.cloudflareaccess.com", "text/html"); - assert!(result.is_some()); - let msg = result.unwrap(); - assert!( - msg.starts_with("relay unreachable:"), - "should have unreachable prefix" - ); - assert!(msg.contains("Cloudflare"), "should mention Cloudflare"); - } - - #[test] - fn intercepted_cloudflare_apex_host_returns_some() { - // The apex domain itself should also match. - let result = classify_intercepted_response("cloudflareaccess.com", "application/json"); - assert!(result.is_some()); - let msg = result.unwrap(); - assert!(msg.starts_with("relay unreachable:")); - assert!(msg.contains("Cloudflare")); - } - - #[test] - fn intercepted_non_cloudflare_html_returns_some() { - let result = - classify_intercepted_response("proxy.corporate.example", "text/html; charset=utf-8"); - assert!(result.is_some()); - let msg = result.unwrap(); - assert!(msg.starts_with("relay unreachable:")); - } - - #[test] - fn normal_relay_json_returns_none() { - let result = classify_intercepted_response("relay.myapp.example.com", "application/json"); - assert!(result.is_none()); - } - - #[test] - fn content_type_case_insensitive() { - // Uppercase content-type must still be detected. - let result = classify_intercepted_response("proxy.example.com", "TEXT/HTML"); - assert!(result.is_some()); - assert!(result.unwrap().starts_with("relay unreachable:")); - } - - #[test] - fn evil_suffix_does_not_match_cloudflare() { - // A host whose suffix happens to contain the Cloudflare string but is - // not actually a subdomain must NOT match. - let result = classify_intercepted_response( - "notcloudflareaccess.com.evil.example", - "application/json", - ); - assert!( - result.is_none(), - "false suffix match should not trigger Cloudflare branch" - ); - } - - // classify_request_error requires a real reqwest::Error (not publicly - // constructable) — tested indirectly through integration; skipped here. - - // ── parse_json_response malformed-body contract ────────────────────────── - - #[test] - fn malformed_response_message_stays_off_unreachable_bucket() { - // A reached-but-malformed 2xx body is not a connectivity failure. If this - // message ever regains the "relay unreachable:" prefix, the frontend - // classifier would misroute it as unreachable — pin that it never does. - assert!( - !MALFORMED_RESPONSE_MESSAGE.starts_with("relay unreachable:"), - "malformed-response message must not match the unreachable prefix" - ); - } - - // ── parse_command_response ─────────────────────────────────────────────── - - #[derive(Debug, Deserialize, PartialEq)] - struct ChannelCreated { - channel_id: String, - } - - #[test] - fn parse_command_response_decodes_typed_payload() { - let msg = r#"response:{"channel_id":"abc123"}"#; - let parsed: ChannelCreated = parse_command_response(msg).expect("should parse"); - assert_eq!( - parsed, - ChannelCreated { - channel_id: "abc123".to_string() - } - ); - } - - #[test] - fn parse_command_response_accepts_raw_json_fallback() { - // Backward-compat: relays that emit raw JSON (no prefix) still work. - let msg = r#"{"channel_id":"abc"}"#; - let parsed: ChannelCreated = parse_command_response(msg).expect("fallback parse"); - assert_eq!( - parsed, - ChannelCreated { - channel_id: "abc".to_string() - } - ); - } - - #[test] - fn parse_command_response_rejects_invalid_prefixed_json() { - let msg = "response:not-json"; - let result: Result = parse_command_response(msg); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("response parse failed")); - } - - #[test] - fn parse_command_response_rejects_garbage() { - let msg = "totally not json or response"; - let result: Result = parse_command_response(msg); - assert!(result.is_err()); - } - - // ── build_profile_event ────────────────────────────────────────────────── - - /// Generate a valid NIP-OA auth tag JSON string signed by a fresh owner key - /// and addressed to `agent_keys`. - /// - /// Uses `nostr_compat` (nostr 0.36) for the owner keys because - /// `buzz_sdk_pkg::nip_oa::compute_auth_tag` expects nostr 0.36 types. - /// The agent pubkey is bridged via hex encoding. - fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { - let owner_keys = nostr::Keys::generate(); - let agent_pubkey_hex = agent_keys.public_key().to_hex(); - let agent_compat_pubkey = - nostr::PublicKey::from_hex(&agent_pubkey_hex).expect("valid hex pubkey should parse"); - buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_compat_pubkey, "") - .expect("compute_auth_tag should not fail with distinct keys") - } - - #[test] - fn profile_event_with_valid_auth_tag() { - let agent_keys = nostr::Keys::generate(); - let tag_json = make_valid_auth_tag(&agent_keys); - let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) - .expect("should succeed with a valid auth tag"); - - // Exactly one "auth" tag must be present. - let auth_tags: Vec<_> = event - .tags - .iter() - .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) - .collect(); - assert_eq!(auth_tags.len(), 1, "expected exactly 1 auth tag"); - - // Must be a kind:0 (Metadata) event. - assert_eq!(event.kind, nostr::Kind::Metadata); - } - - #[test] - fn profile_event_without_auth_tag() { - let agent_keys = nostr::Keys::generate(); - let event = build_profile_event(&agent_keys, "TestBot", None, None) - .expect("should succeed without an auth tag"); - - // No "auth" tags should be present. - let auth_tags: Vec<_> = event - .tags - .iter() - .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) - .collect(); - assert_eq!(auth_tags.len(), 0, "expected no auth tags"); - - assert_eq!(event.kind, nostr::Kind::Metadata); - } - - #[test] - fn profile_event_rejects_invalid_auth_tag() { - let agent_keys = nostr::Keys::generate(); - // Structurally valid JSON array but with a bogus signature — verification must fail. - let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); - let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); - assert!(result.is_err(), "should reject an invalid auth tag"); - assert!( - result.unwrap_err().contains("verification failed"), - "error message should mention verification failure" - ); - } -} +#[path = "relay_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/relay_tests.rs b/desktop/src-tauri/src/relay_tests.rs new file mode 100644 index 0000000000..20a0b8a240 --- /dev/null +++ b/desktop/src-tauri/src/relay_tests.rs @@ -0,0 +1,375 @@ +//! Unit tests for the relay module. Split from `relay.rs` for file-size +//! discipline; wired via `#[path]` from that file's `mod tests`. + +use super::{ + build_profile_event, classify_intercepted_response, effective_agent_relay_url, + extract_retry_in_hint, parse_command_response, relay_http_base_url, MALFORMED_RESPONSE_MESSAGE, +}; +use serde::Deserialize; + +// ── extract_retry_in_hint ──────────────────────────────────────────────── + +#[test] +fn extracts_hint_from_429_body() { + assert_eq!( + extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded; retry in 4s"}"#), + Some(4) + ); +} + +#[test] +fn extracts_hint_when_no_json_wrapper() { + assert_eq!(extract_retry_in_hint("retry in 30s"), Some(30)); +} + +#[test] +fn returns_none_when_no_hint_present() { + assert_eq!( + extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded"}"#), + None + ); + assert_eq!(extract_retry_in_hint(""), None); +} + +#[test] +fn overlong_digit_string_returns_none() { + // A digit sequence that exceeds u64::MAX cannot be parsed; the function + // must return None (→ caller uses the default) rather than panicking. + assert_eq!( + extract_retry_in_hint("retry in 99999999999999999999999s"), + None + ); +} + +// ── relay_error_message: hint capping ──────────────────────────────────── +// +// Verify that an oversized relay hint is capped in the returned message +// string, not just inside `activate_rate_limit()`. This guarantees every +// consumer — including the TS gate via `applyTauriRateLimitIfNeeded` — +// receives the capped value rather than the raw untrusted relay value. + +#[tokio::test] +async fn oversized_hint_is_capped_in_relay_error_message_string() { + use crate::relay_admission::MAX_HINT_SECONDS; + use std::io::{Read as _, Write as _}; + + // Use a std::net listener on a std::thread — the same pattern as the + // relay_admission loopback tests. This avoids two races that cause CI + // failures with tokio::net + into_std(): + // 1. No request read: the client is still sending when the response + // arrives → hyper `UnexpectedMessage`/`Canceled` under load. + // 2. into_std() leaves the socket in nonblocking mode → write_all + // may return WouldBlock and silently drop the response. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + // Serve a 429 with a hint far exceeding MAX_HINT_SECONDS (300). + let oversized = 1_000_000u64; + let body = format!(r#"{{"error":"rate-limited: quota exceeded; retry in {oversized}s"}}"#); + let body_len = body.len(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + // Read the request first so the client finishes sending before + // we write the response — mirrors relay_admission.rs pattern. + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n{body}" + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + let client = reqwest::Client::new(); + let response = client + .get(format!("http://{addr}/")) + .send() + .await + .expect("request must succeed"); + + let msg = super::relay_error_message(response).await; + + // The message must embed the CAPPED hint, not the raw 1 000 000. + assert_eq!( + msg, + format!("relay rate-limited: retry in {MAX_HINT_SECONDS}s"), + "relay_error_message must embed the capped hint, not the raw untrusted value" + ); + assert!( + !msg.contains(&oversized.to_string()), + "raw oversized hint must not appear in the message string" + ); +} + +// ── effective_agent_relay_url: legacy pin ignored ───────────────────────── + +#[test] +fn stored_relay_pin_is_ignored() { + // Zero-touch cutover (#2122): a creation-era per-record relay pin is + // parsed and persisted but never consulted — the workspace relay wins. + assert_eq!( + effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"), + "wss://staging.example.com" + ); +} + +#[test] +fn empty_relay_resolves_to_workspace() { + // A never-set record resolves to the active workspace relay at read-time, + // so a stale stored default can never make it load-bearing. + assert_eq!( + effective_agent_relay_url("", "wss://staging.example.com"), + "wss://staging.example.com" + ); +} + +#[test] +fn whitespace_only_relay_resolves_to_workspace() { + // Whitespace-only behaves identically — no value survives. + assert_eq!( + effective_agent_relay_url(" ", "wss://staging.example.com"), + "wss://staging.example.com" + ); +} + +// ── relay_http_base_url scheme conversion ──────────────────────────────── + +#[test] +fn loopback_ws_localhost_preserves_authority() { + // Tenant host-binding keys off the HTTP Host/authority. The desktop must + // not rewrite localhost to 127.0.0.1, or local dev HTTP calls target a + // different unmapped community than the WebSocket URL. + assert_eq!( + relay_http_base_url("ws://localhost:3000"), + "http://localhost:3000" + ); +} + +#[test] +fn loopback_trailing_slash_removed_authority_preserved() { + assert_eq!( + relay_http_base_url("ws://localhost:3000/"), + "http://localhost:3000" + ); +} + +#[test] +fn remote_wss_host_unchanged() { + assert_eq!( + relay_http_base_url("wss://relay.example.com"), + "https://relay.example.com" + ); +} + +#[test] +fn loopback_ipv4_literal_unchanged() { + assert_eq!( + relay_http_base_url("ws://127.0.0.1:3000"), + "http://127.0.0.1:3000" + ); +} + +#[test] +fn localhost_substring_host_unchanged() { + assert_eq!( + relay_http_base_url("ws://localhost.evil.com:3000"), + "http://localhost.evil.com:3000" + ); +} + +#[test] +fn loopback_wss_localhost_preserves_authority() { + assert_eq!( + relay_http_base_url("wss://localhost:3000"), + "https://localhost:3000" + ); +} + +// ── classify_intercepted_response ──────────────────────────────────────── + +#[test] +fn intercepted_cloudflare_host_returns_some() { + let result = classify_intercepted_response("sqprod.cloudflareaccess.com", "text/html"); + assert!(result.is_some()); + let msg = result.unwrap(); + assert!( + msg.starts_with("relay unreachable:"), + "should have unreachable prefix" + ); + assert!(msg.contains("Cloudflare"), "should mention Cloudflare"); +} + +#[test] +fn intercepted_cloudflare_apex_host_returns_some() { + // The apex domain itself should also match. + let result = classify_intercepted_response("cloudflareaccess.com", "application/json"); + assert!(result.is_some()); + let msg = result.unwrap(); + assert!(msg.starts_with("relay unreachable:")); + assert!(msg.contains("Cloudflare")); +} + +#[test] +fn intercepted_non_cloudflare_html_returns_some() { + let result = + classify_intercepted_response("proxy.corporate.example", "text/html; charset=utf-8"); + assert!(result.is_some()); + let msg = result.unwrap(); + assert!(msg.starts_with("relay unreachable:")); +} + +#[test] +fn normal_relay_json_returns_none() { + let result = classify_intercepted_response("relay.myapp.example.com", "application/json"); + assert!(result.is_none()); +} + +#[test] +fn content_type_case_insensitive() { + // Uppercase content-type must still be detected. + let result = classify_intercepted_response("proxy.example.com", "TEXT/HTML"); + assert!(result.is_some()); + assert!(result.unwrap().starts_with("relay unreachable:")); +} + +#[test] +fn evil_suffix_does_not_match_cloudflare() { + // A host whose suffix happens to contain the Cloudflare string but is + // not actually a subdomain must NOT match. + let result = + classify_intercepted_response("notcloudflareaccess.com.evil.example", "application/json"); + assert!( + result.is_none(), + "false suffix match should not trigger Cloudflare branch" + ); +} + +// classify_request_error requires a real reqwest::Error (not publicly +// constructable) — tested indirectly through integration; skipped here. + +// ── parse_json_response malformed-body contract ────────────────────────── + +#[test] +fn malformed_response_message_stays_off_unreachable_bucket() { + // A reached-but-malformed 2xx body is not a connectivity failure. If this + // message ever regains the "relay unreachable:" prefix, the frontend + // classifier would misroute it as unreachable — pin that it never does. + assert!( + !MALFORMED_RESPONSE_MESSAGE.starts_with("relay unreachable:"), + "malformed-response message must not match the unreachable prefix" + ); +} + +// ── parse_command_response ─────────────────────────────────────────────── + +#[derive(Debug, Deserialize, PartialEq)] +struct ChannelCreated { + channel_id: String, +} + +#[test] +fn parse_command_response_decodes_typed_payload() { + let msg = r#"response:{"channel_id":"abc123"}"#; + let parsed: ChannelCreated = parse_command_response(msg).expect("should parse"); + assert_eq!( + parsed, + ChannelCreated { + channel_id: "abc123".to_string() + } + ); +} + +#[test] +fn parse_command_response_accepts_raw_json_fallback() { + // Backward-compat: relays that emit raw JSON (no prefix) still work. + let msg = r#"{"channel_id":"abc"}"#; + let parsed: ChannelCreated = parse_command_response(msg).expect("fallback parse"); + assert_eq!( + parsed, + ChannelCreated { + channel_id: "abc".to_string() + } + ); +} + +#[test] +fn parse_command_response_rejects_invalid_prefixed_json() { + let msg = "response:not-json"; + let result: Result = parse_command_response(msg); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("response parse failed")); +} + +#[test] +fn parse_command_response_rejects_garbage() { + let msg = "totally not json or response"; + let result: Result = parse_command_response(msg); + assert!(result.is_err()); +} + +// ── build_profile_event ────────────────────────────────────────────────── + +/// Generate a valid NIP-OA auth tag JSON string signed by a fresh owner key +/// and addressed to `agent_keys`. +/// +/// Uses `nostr_compat` (nostr 0.36) for the owner keys because +/// `buzz_sdk_pkg::nip_oa::compute_auth_tag` expects nostr 0.36 types. +/// The agent pubkey is bridged via hex encoding. +fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { + let owner_keys = nostr::Keys::generate(); + let agent_pubkey_hex = agent_keys.public_key().to_hex(); + let agent_compat_pubkey = + nostr::PublicKey::from_hex(&agent_pubkey_hex).expect("valid hex pubkey should parse"); + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_compat_pubkey, "") + .expect("compute_auth_tag should not fail with distinct keys") +} + +#[test] +fn profile_event_with_valid_auth_tag() { + let agent_keys = nostr::Keys::generate(); + let tag_json = make_valid_auth_tag(&agent_keys); + let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) + .expect("should succeed with a valid auth tag"); + + // Exactly one "auth" tag must be present. + let auth_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .collect(); + assert_eq!(auth_tags.len(), 1, "expected exactly 1 auth tag"); + + // Must be a kind:0 (Metadata) event. + assert_eq!(event.kind, nostr::Kind::Metadata); +} + +#[test] +fn profile_event_without_auth_tag() { + let agent_keys = nostr::Keys::generate(); + let event = build_profile_event(&agent_keys, "TestBot", None, None) + .expect("should succeed without an auth tag"); + + // No "auth" tags should be present. + let auth_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .collect(); + assert_eq!(auth_tags.len(), 0, "expected no auth tags"); + + assert_eq!(event.kind, nostr::Kind::Metadata); +} + +#[test] +fn profile_event_rejects_invalid_auth_tag() { + let agent_keys = nostr::Keys::generate(); + // Structurally valid JSON array but with a bogus signature — verification must fail. + let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); + let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); + assert!(result.is_err(), "should reject an invalid auth tag"); + assert!( + result.unwrap_err().contains("verification failed"), + "error message should mention verification failure" + ); +}