Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions e2e/app.smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ test('preserves the local demo routes alongside the default app', async ({ page
await expect(page).toHaveURL('http://127.0.0.1:4174/?demo=minimal');
await expect(page.getByText('Chats', { exact: true })).toBeVisible();
await expect(page.getByText('Familiars', { exact: true })).toBeVisible();

await page.goto('/?demo=familiars-reads');

await expect(page).toHaveURL('http://127.0.0.1:4174/?demo=familiars-reads');
await expect(page.getByRole('complementary', { name: 'Conversations sidebar' })).toBeVisible();
await expect(page.getByRole('button', { name: /Q3 pricing evidence map/ })).toBeVisible();
// Stage 1 has no send capability yet; the composer notice says so rather
// than offering a working-looking control.
await expect(page.getByText(/^Sending: Not available yet/)).toBeVisible();
});

test('keeps the chat sidebar compact and fully interactive', async ({ page }) => {
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const NATIVE_COMMANDS: &[&str] = &[
"cave_list_conversations",
"cave_get_conversation",
"cave_list_conversation_messages",
"cave_familiar_contract",
"cave_familiar_analytics",
];

fn tauri_config_value<'a>(config: &'a serde_json::Value, key: &str) -> &'a str {
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-familiar-contract",
"allow-cave-familiar-analytics"
]
}
24 changes: 24 additions & 0 deletions src-tauri/gen/schemas/desktop-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,18 @@
"const": "allow-cave-credential-status",
"markdownDescription": "Enables the cave_credential_status command without any pre-configured scope."
},
{
"description": "Enables the cave_familiar_analytics command without any pre-configured scope.",
"type": "string",
"const": "allow-cave-familiar-analytics",
"markdownDescription": "Enables the cave_familiar_analytics command without any pre-configured scope."
},
{
"description": "Enables the cave_familiar_contract command without any pre-configured scope.",
"type": "string",
"const": "allow-cave-familiar-contract",
"markdownDescription": "Enables the cave_familiar_contract command without any pre-configured scope."
},
{
"description": "Enables the cave_forget_credential command without any pre-configured scope.",
"type": "string",
Expand Down Expand Up @@ -308,6 +320,18 @@
"const": "deny-cave-credential-status",
"markdownDescription": "Denies the cave_credential_status command without any pre-configured scope."
},
{
"description": "Denies the cave_familiar_analytics command without any pre-configured scope.",
"type": "string",
"const": "deny-cave-familiar-analytics",
"markdownDescription": "Denies the cave_familiar_analytics command without any pre-configured scope."
},
{
"description": "Denies the cave_familiar_contract command without any pre-configured scope.",
"type": "string",
"const": "deny-cave-familiar-contract",
"markdownDescription": "Denies the cave_familiar_contract command without any pre-configured scope."
},
{
"description": "Denies the cave_forget_credential command without any pre-configured scope.",
"type": "string",
Expand Down
47 changes: 47 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub const REGISTERED_COMMANDS: &[&str] = &[
"cave_list_conversations",
"cave_get_conversation",
"cave_list_conversation_messages",
"cave_familiar_contract",
"cave_familiar_analytics",
];

#[tauri::command]
Expand Down Expand Up @@ -289,6 +291,51 @@ pub async fn cave_list_conversation_messages(
.await
}

#[tauri::command]
pub async fn cave_familiar_contract(
handle: String,
familiar_id: String,
operation: NativeOperationInput,
state: State<'_, NativeConnectionState>,
) -> Result<Value, NativeDiagnostic> {
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_familiar_analytics(
handle: String,
familiar_id: String,
window: Option<String>,
recent_limit: Option<u8>,
operation: NativeOperationInput,
state: State<'_, NativeConnectionState>,
) -> Result<Value, NativeDiagnostic> {
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
}

pub fn registered_command_names() -> &'static [&'static str] {
REGISTERED_COMMANDS
}
15 changes: 10 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_familiar_analytics, cave_familiar_contract, 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,
};
pub use coven::CovenHealthResult;
pub use metadata::{AppIdentity, APP_IDENTIFIER, APP_NAME, APP_PHASE};
Expand Down Expand Up @@ -267,7 +268,9 @@ fn builder() -> tauri::Builder<tauri::Wry> {
cave_list_projects,
cave_list_conversations,
cave_get_conversation,
cave_list_conversation_messages
cave_list_conversation_messages,
cave_familiar_contract,
cave_familiar_analytics
])
}

Expand Down Expand Up @@ -462,6 +465,8 @@ mod smoke_tests {
"cave_list_conversations",
"cave_get_conversation",
"cave_list_conversation_messages",
"cave_familiar_contract",
"cave_familiar_analytics",
]
);
}
Expand Down
121 changes: 121 additions & 0 deletions src-tauri/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ pub(crate) enum CaveReadPath {
conversation_id: String,
page: NativePage,
},
FamiliarContract {
familiar_id: String,
},
FamiliarAnalytics {
familiar_id: String,
window: Option<String>,
recent_limit: Option<u8>,
},
}

#[derive(Clone, Deserialize)]
Expand Down Expand Up @@ -175,6 +183,23 @@ 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)?;
if let Some(window) = window {
if !matches!(window.as_str(), "7d" | "14d" | "8w" | "all") {
return Err(NativeDiagnostic::new("invalid_native_input", false));
}
}
if recent_limit.is_some_and(|limit| limit > 100) {
return Err(NativeDiagnostic::new("invalid_native_input", false));
}
Ok(())
}
}
}
}
Expand Down Expand Up @@ -215,6 +240,20 @@ fn validate_canonical_conversation_id(value: &str) -> NativeResult<()> {
Ok(())
}

fn validate_canonical_familiar_id(value: &str) -> NativeResult<()> {
let bytes = value.as_bytes();
if bytes.is_empty()
|| bytes.len() > 64
|| !bytes[0].is_ascii_alphanumeric()
|| !bytes
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(NativeDiagnostic::new("invalid_native_input", false));
}
Ok(())
}

impl ConstrainedTransport {
fn client() -> NativeResult<Client> {
Client::builder()
Expand Down Expand Up @@ -414,6 +453,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,
recent_limit,
),
None,
),
};
let path = with_page(path, page)?;
Self::request(authority, Method::GET, &path, Some(bearer), None, None).await
Expand Down Expand Up @@ -500,6 +561,26 @@ fn with_page(mut path: String, page: Option<NativePage>) -> NativeResult<String>
Ok(path)
}

fn with_analytics_query(
mut path: String,
window: Option<String>,
recent_limit: Option<u8>,
) -> 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(limit) = recent_limit {
serializer.append_pair("recent", &limit.to_string());
}
path.push('?');
path.push_str(&serializer.finish());
path
}

async fn read_response(mut response: reqwest::Response) -> NativeResult<NativeHttpResponse> {
if response.status().is_redirection() {
return Err(NativeDiagnostic::new("redirect_rejected", false));
Expand Down Expand Up @@ -793,6 +874,46 @@ mod tests {
}
}

#[test]
fn canonical_familiar_ids_are_bounded_to_one_unescaped_path_segment() {
assert_eq!(encoded_cave_path_segment("astra-01"), "astra-01");
let too_long = "a".repeat(65);
for familiar_id in [
"", ".", "..", "a/b", "space id", "雪", "percent%", &too_long,
] {
assert!(CaveReadPath::FamiliarContract {
familiar_id: familiar_id.to_owned(),
}
.validate()
.is_err());
}
}

#[test]
fn familiar_analytics_rejects_out_of_bound_window_and_recent_limit() {
assert!(CaveReadPath::FamiliarAnalytics {
familiar_id: "astra".to_owned(),
window: Some("30d".to_owned()),
recent_limit: None,
}
.validate()
.is_err());
assert!(CaveReadPath::FamiliarAnalytics {
familiar_id: "astra".to_owned(),
window: None,
recent_limit: Some(101),
}
.validate()
.is_err());
assert!(CaveReadPath::FamiliarAnalytics {
familiar_id: "astra".to_owned(),
window: Some("7d".to_owned()),
recent_limit: Some(100),
}
.validate()
.is_ok());
}

#[test]
fn endpoint_takeover_receives_ciphertext_only_and_cannot_forge_a_response() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
2 changes: 2 additions & 0 deletions src/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ function makeQueryAdapter(): QueryAdapter {
],
},
}),
familiarContract: vi.fn().mockResolvedValue({ status: 'not_ready' }),
familiarAnalytics: vi.fn().mockResolvedValue({ status: 'not_ready' }),
invalidate: vi.fn(),
dispose: vi.fn(),
};
Expand Down
2 changes: 2 additions & 0 deletions src/chat-shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ function makeQueryAdapter(overrides: Partial<QueryAdapter> = {}): QueryAdapter {
},
]),
),
familiarContract: vi.fn().mockResolvedValue({ status: 'not_ready' }),
familiarAnalytics: vi.fn().mockResolvedValue({ status: 'not_ready' }),
invalidate: vi.fn(),
dispose: vi.fn(),
...overrides,
Expand Down
Loading
Loading