diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 4d093438..6f781aa8 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -19,6 +19,8 @@ const NATIVE_COMMANDS: &[&str] = &[ "cave_list_conversations", "cave_get_conversation", "cave_list_conversation_messages", + "cave_get_familiar_contract", + "cave_get_familiar_analytics", ]; fn tauri_config_value<'a>(config: &'a serde_json::Value, key: &str) -> &'a str { diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b8eb9a5d..b908a854 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -21,6 +21,8 @@ "allow-cave-list-projects", "allow-cave-list-conversations", "allow-cave-get-conversation", - "allow-cave-list-conversation-messages" + "allow-cave-list-conversation-messages", + "allow-cave-get-familiar-contract", + "allow-cave-get-familiar-analytics" ] } diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index 8850d16f..7c3a9a0f 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -212,6 +212,18 @@ "const": "allow-cave-get-conversation", "markdownDescription": "Enables the cave_get_conversation command without any pre-configured scope." }, + { + "description": "Enables the cave_get_familiar_analytics command without any pre-configured scope.", + "type": "string", + "const": "allow-cave-get-familiar-analytics", + "markdownDescription": "Enables the cave_get_familiar_analytics command without any pre-configured scope." + }, + { + "description": "Enables the cave_get_familiar_contract command without any pre-configured scope.", + "type": "string", + "const": "allow-cave-get-familiar-contract", + "markdownDescription": "Enables the cave_get_familiar_contract command without any pre-configured scope." + }, { "description": "Enables the cave_health command without any pre-configured scope.", "type": "string", @@ -320,6 +332,18 @@ "const": "deny-cave-get-conversation", "markdownDescription": "Denies the cave_get_conversation command without any pre-configured scope." }, + { + "description": "Denies the cave_get_familiar_analytics command without any pre-configured scope.", + "type": "string", + "const": "deny-cave-get-familiar-analytics", + "markdownDescription": "Denies the cave_get_familiar_analytics command without any pre-configured scope." + }, + { + "description": "Denies the cave_get_familiar_contract command without any pre-configured scope.", + "type": "string", + "const": "deny-cave-get-familiar-contract", + "markdownDescription": "Denies the cave_get_familiar_contract command without any pre-configured scope." + }, { "description": "Denies the cave_health command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cdf718bf..34494c58 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -28,6 +28,8 @@ pub const REGISTERED_COMMANDS: &[&str] = &[ "cave_list_conversations", "cave_get_conversation", "cave_list_conversation_messages", + "cave_get_familiar_contract", + "cave_get_familiar_analytics", ]; #[tauri::command] @@ -292,3 +294,48 @@ pub async fn cave_list_conversation_messages( pub fn registered_command_names() -> &'static [&'static str] { REGISTERED_COMMANDS } + +#[tauri::command] +pub async fn cave_get_familiar_contract( + handle: String, + familiar_id: String, + operation: NativeOperationInput, + state: State<'_, NativeConnectionState>, +) -> Result { + let runner = state.inner().clone(); + let operation_state = runner.clone(); + runner + .run_operation(operation, async move { + operation_state + .cave_read(handle, CaveReadPath::FamiliarContract { familiar_id }) + .await + }) + .await +} + +#[tauri::command] +pub async fn cave_get_familiar_analytics( + handle: String, + familiar_id: String, + window: Option, + recent_limit: Option, + operation: NativeOperationInput, + state: State<'_, NativeConnectionState>, +) -> Result { + let runner = state.inner().clone(); + let operation_state = runner.clone(); + runner + .run_operation(operation, async move { + operation_state + .cave_read( + handle, + CaveReadPath::FamiliarAnalytics { + familiar_id, + window, + recent_limit, + }, + ) + .await + }) + .await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62d08ca7..d68a873f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,10 +29,11 @@ use transport::{ConstrainedTransport, NativeCaveTransport}; pub use commands::{ app_identity, app_installation_id, cave_cancel_operation, cave_credential_status, - cave_forget_credential, cave_get_conversation, cave_health, cave_launch, - cave_list_conversation_messages, cave_list_conversations, cave_list_familiars, - cave_list_projects, cave_pairing_create, cave_pairing_exchange, cave_pairing_poll, - cave_read_discovery, cave_reset_pairing, coven_health, registered_command_names, + cave_forget_credential, cave_get_conversation, cave_get_familiar_analytics, + cave_get_familiar_contract, cave_health, cave_launch, cave_list_conversation_messages, + cave_list_conversations, cave_list_familiars, cave_list_projects, cave_pairing_create, + cave_pairing_exchange, cave_pairing_poll, cave_read_discovery, cave_reset_pairing, + coven_health, registered_command_names, }; pub use coven::CovenHealthResult; pub use metadata::{AppIdentity, APP_IDENTIFIER, APP_NAME, APP_PHASE}; @@ -267,7 +268,9 @@ fn builder() -> tauri::Builder { cave_list_projects, cave_list_conversations, cave_get_conversation, - cave_list_conversation_messages + cave_list_conversation_messages, + cave_get_familiar_contract, + cave_get_familiar_analytics ]) } @@ -462,6 +465,8 @@ mod smoke_tests { "cave_list_conversations", "cave_get_conversation", "cave_list_conversation_messages", + "cave_get_familiar_contract", + "cave_get_familiar_analytics", ] ); } diff --git a/src-tauri/src/transport.rs b/src-tauri/src/transport.rs index cd9d843e..52e8864b 100644 --- a/src-tauri/src/transport.rs +++ b/src-tauri/src/transport.rs @@ -92,6 +92,14 @@ pub(crate) enum CaveReadPath { conversation_id: String, page: NativePage, }, + FamiliarContract { + familiar_id: String, + }, + FamiliarAnalytics { + familiar_id: String, + window: Option, + recent_limit: Option, + }, } #[derive(Clone, Deserialize)] @@ -175,10 +183,32 @@ impl CaveReadPath { validate_canonical_conversation_id(conversation_id)?; page.validate() } + Self::FamiliarContract { familiar_id } => validate_canonical_familiar_id(familiar_id), + Self::FamiliarAnalytics { + familiar_id, + window, + recent_limit, + } => { + validate_canonical_familiar_id(familiar_id)?; + // Cave refuses an unknown window or an out-of-range recent + // count rather than correcting it, so the same values are + // refused here instead of being sent to be rejected there. + if window + .as_deref() + .is_some_and(|window| !CANONICAL_ANALYTICS_WINDOWS.contains(&window)) + || recent_limit.is_some_and(|recent| recent > 100) + { + return Err(NativeDiagnostic::new("invalid_native_input", false)); + } + Ok(()) + } } } } +/// The windows Cave aggregates over. A client may narrow to exactly one. +const CANONICAL_ANALYTICS_WINDOWS: [&str; 4] = ["7d", "14d", "8w", "all"]; + fn is_canonical_cursor(value: &str) -> bool { if value.is_empty() || value.len() > 512 @@ -202,6 +232,26 @@ fn is_canonical_cursor(value: &str) -> bool { trailing != usize::MAX && trailing % (if remainder == 2 { 16 } else { 4 }) == 0 } +/// A familiar id, held to the slug allow-list Cave itself enforces. +/// +/// Narrower than a conversation id on purpose: Cave's `isValidFamiliarId` +/// accepts `[a-z0-9][a-z0-9_-]{0,63}` case-insensitively and nothing else, so +/// an id this host would have to percent-encode is one Cave could never carry. +/// Refusing it here means a traversal-shaped id never reaches the wire at all. +fn validate_canonical_familiar_id(value: &str) -> NativeResult<()> { + let bytes = value.as_bytes(); + let leads = bytes.first().is_some_and(u8::is_ascii_alphanumeric); + if !leads + || bytes.len() > 64 + || !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(NativeDiagnostic::new("invalid_native_input", false)); + } + Ok(()) +} + fn validate_canonical_conversation_id(value: &str) -> NativeResult<()> { if value.trim().is_empty() || matches!(value, "." | "..") @@ -414,6 +464,28 @@ impl NativeCaveTransport for ConstrainedTransport { }, Some(page), ), + CaveReadPath::FamiliarContract { familiar_id } => ( + format!( + "api/client/v1/familiars/{}/contract", + encoded_cave_path_segment(&familiar_id) + ), + None, + ), + CaveReadPath::FamiliarAnalytics { + familiar_id, + window, + recent_limit, + } => ( + with_analytics_query( + format!( + "api/client/v1/familiars/{}/analytics", + encoded_cave_path_segment(&familiar_id) + ), + window.as_deref(), + recent_limit, + ), + None, + ), }; let path = with_page(path, page)?; Self::request(authority, Method::GET, &path, Some(bearer), None, None).await @@ -480,6 +552,31 @@ pub(crate) fn encoded_cave_path_segment(value: &str) -> String { .collect() } +/// `window` before `recent`, and only what the caller set. +/// +/// Both values are already proven by `CaveReadPath::validate`, and Cave +/// refuses a parameter it does not serve rather than ignoring it, so the query +/// carries exactly the narrowing that was asked for and nothing implied. +fn with_analytics_query( + mut path: String, + window: Option<&str>, + recent_limit: Option, +) -> String { + if window.is_none() && recent_limit.is_none() { + return path; + } + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + if let Some(window) = window { + serializer.append_pair("window", window); + } + if let Some(recent) = recent_limit { + serializer.append_pair("recent", &recent.to_string()); + } + path.push('?'); + path.push_str(&serializer.finish()); + path +} + fn with_page(mut path: String, page: Option) -> NativeResult { let Some(page) = page else { return Ok(path); @@ -625,7 +722,8 @@ mod tests { use super::{ encoded_cave_path_segment, managed_pairing_created, response_data, response_diagnostic, - CaveReadPath, ConstrainedTransport, NativeCaveTransport, NativeHttpResponse, NativePage, + with_analytics_query, CaveReadPath, ConstrainedTransport, NativeCaveTransport, + NativeHttpResponse, NativePage, }; use crate::cave::{ pin_owner_discovery_record, OwnerDiscoveryRecord, OwnerDiscoveryRecordMetadata, @@ -793,6 +891,99 @@ mod tests { } } + #[test] + fn canonical_familiar_ids_are_bounded_to_caves_slug_allow_list() { + for familiar_id in ["scribe", "cody", "a", "a-b_c9", &"a".repeat(64)] { + assert!( + CaveReadPath::FamiliarContract { + familiar_id: familiar_id.to_owned(), + } + .validate() + .is_ok(), + "{familiar_id} is a Cave familiar slug" + ); + } + // Narrower than a conversation id: `.` and `~` are legal there and not + // here, because Cave's own allow-list does not carry them. Anything + // this host would have to percent-encode is refused before the wire. + for familiar_id in [ + "", + ".", + "..", + "a/b", + "space id", + "雪", + "percent%", + "-leading", + "_leading", + "dot.name", + "tilde~name", + &"a".repeat(65), + ] { + assert!( + CaveReadPath::FamiliarContract { + familiar_id: familiar_id.to_owned(), + } + .validate() + .is_err(), + "{familiar_id} is not a Cave familiar slug" + ); + } + } + + #[test] + fn familiar_analytics_narrowing_is_refused_before_it_reaches_cave() { + let analytics = + |window: Option<&str>, recent: Option| CaveReadPath::FamiliarAnalytics { + familiar_id: "scribe".to_owned(), + window: window.map(str::to_owned), + recent_limit: recent, + }; + for window in ["7d", "14d", "8w", "all"] { + assert!(analytics(Some(window), None).validate().is_ok(), "{window}"); + } + assert!(analytics(None, None).validate().is_ok()); + assert!(analytics(None, Some(0)).validate().is_ok()); + assert!(analytics(None, Some(100)).validate().is_ok()); + // Cave refuses these rather than correcting them, so they never leave. + for window in ["3d", "7D", "", "all "] { + assert!( + analytics(Some(window), None).validate().is_err(), + "{window}" + ); + } + assert!(analytics(None, Some(101)).validate().is_err()); + assert!(analytics(Some("7d"), Some(101)).validate().is_err()); + } + + #[test] + fn familiar_routes_carry_only_the_narrowing_that_was_asked_for() { + assert_eq!( + with_analytics_query( + "api/client/v1/familiars/scribe/analytics".to_owned(), + None, + None + ), + "api/client/v1/familiars/scribe/analytics" + ); + assert_eq!( + with_analytics_query( + "api/client/v1/familiars/scribe/analytics".to_owned(), + Some("7d"), + Some(5), + ), + "api/client/v1/familiars/scribe/analytics?window=7d&recent=5" + ); + assert_eq!( + with_analytics_query( + "api/client/v1/familiars/scribe/analytics".to_owned(), + None, + Some(0), + ), + "api/client/v1/familiars/scribe/analytics?recent=0" + ); + } + #[test] fn endpoint_takeover_receives_ciphertext_only_and_cannot_forge_a_response() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/src/specification-guards.test.ts b/src/specification-guards.test.ts index ad659378..88129e2a 100644 --- a/src/specification-guards.test.ts +++ b/src/specification-guards.test.ts @@ -227,6 +227,8 @@ describe('Phase 1 specification guards', () => { 'allow-cave-list-conversations', 'allow-cave-get-conversation', 'allow-cave-list-conversation-messages', + 'allow-cave-get-familiar-contract', + 'allow-cave-get-familiar-analytics', ]); for (const permission of capability.permissions) { @@ -680,6 +682,8 @@ describe('Phase 1 specification guards', () => { 'cave_list_conversations', 'cave_get_conversation', 'cave_list_conversation_messages', + 'cave_get_familiar_contract', + 'cave_get_familiar_analytics', ]) { expect(buildScript).toContain(`"${command}"`); } @@ -706,6 +710,8 @@ describe('Phase 1 specification guards', () => { 'cave_list_conversations', 'cave_get_conversation', 'cave_list_conversation_messages', + 'cave_get_familiar_contract', + 'cave_get_familiar_analytics', ]; const schemaCommands = [...schema.matchAll(/"const": "(?:allow|deny)-([^"]+)"/g)] .map((match) => match[1]?.replaceAll('-', '_') ?? '') @@ -743,6 +749,8 @@ describe('Phase 1 specification guards', () => { 'cave_list_conversations', 'cave_get_conversation', 'cave_list_conversation_messages', + 'cave_get_familiar_contract', + 'cave_get_familiar_analytics', ]; expect(registeredCommandNames(commands)).toEqual(expected);