use crate::kernel::WorkspaceSourceKind; use serde::de; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AiSession { pub session_id: String, pub workspace_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub document_id: Option, pub owner: AiRuntimeOwner, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum AiRuntimeOwner { RustWebHermes, CompatReactIsland, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AiToolCall { pub call_id: String, pub tool_name: String, pub args: Value, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AiEvent { pub event_id: String, pub session_id: String, pub kind: AiEventKind, #[serde(default)] pub payload: Value, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum AiEventKind { SessionCreated, ToolCallPlanned, ToolCallApplied, StructuredWriteCompleted, Error, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AiStructuredWriteResult { pub ok: bool, pub write_kind: AiStructuredWriteKind, pub command_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub target_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub revision: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum AiStructuredWriteKind { SummaryNode, AiNoteNode, ReferenceEdge, PageBody, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AiAccessPermissionLevel { Admin, ReadOnly, ReadWrite, SharedRead, SharedWrite, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AiShareContext { #[serde(default, skip_serializing_if = "Option::is_none")] pub share_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub mode: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AiAccessScope { pub user_id: String, pub workspace_id: String, pub session_id: String, pub source_kind: WorkspaceSourceKind, pub permission_level: AiAccessPermissionLevel, #[serde(default)] pub allowed_roots: Vec, #[serde(default)] pub allowed_file_paths: Vec, #[serde(default)] pub allowed_resource_ids: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub share_context: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct AiAccessScopeRaw { user_id: String, workspace_id: String, session_id: String, source_kind: WorkspaceSourceKind, permission_level: AiAccessPermissionLevel, #[serde(default)] allowed_roots: Vec, #[serde(default)] allowed_file_paths: Vec, #[serde(default)] allowed_resource_ids: Vec, #[serde(default)] share_context: Option, } impl<'de> Deserialize<'de> for AiAccessScope { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let raw = AiAccessScopeRaw::deserialize(deserializer)?; let scope = Self { user_id: raw.user_id, workspace_id: raw.workspace_id, session_id: raw.session_id, source_kind: raw.source_kind, permission_level: raw.permission_level, allowed_roots: raw.allowed_roots, allowed_file_paths: raw.allowed_file_paths, allowed_resource_ids: raw.allowed_resource_ids, share_context: raw.share_context, }; scope.validate().map_err(de::Error::custom)?; Ok(scope) } } impl AiAccessScope { pub fn validate(&self) -> Result<(), String> { reject_blank("userId", &self.user_id)?; reject_blank("workspaceId", &self.workspace_id)?; reject_blank("sessionId", &self.session_id)?; reject_blank_vec("allowedRoots", &self.allowed_roots)?; reject_blank_vec("allowedFilePaths", &self.allowed_file_paths)?; reject_blank_vec("allowedResourceIds", &self.allowed_resource_ids)?; if let Some(share_context) = &self.share_context { if let Some(share_id) = &share_context.share_id { reject_blank("shareContext.shareId", share_id)?; } if let Some(mode) = &share_context.mode { reject_blank("shareContext.mode", mode)?; } } Ok(()) } } fn reject_blank(field: &str, value: &str) -> Result<(), String> { if value.trim().is_empty() { return Err(format!("{field} must not be blank")); } Ok(()) } fn reject_blank_vec(field: &str, values: &[String]) -> Result<(), String> { for value in values { reject_blank(field, value)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::WorkspaceSourceKind; use serde_json::json; #[test] fn ai_access_scope_uses_camel_case_payload() { let scope: AiAccessScope = serde_json::from_value(json!({ "userId": "user_1", "workspaceId": "workspace_1", "sessionId": "session_1", "sourceKind": "local_folder", "permissionLevel": "read_write", "allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"], "allowedFilePaths": ["file:///mnt/Data1T/Mnote_data/users/user_1/我的空间/README.md"], "allowedResourceIds": ["local-md:README.md"], "shareContext": { "shareId": "share_1", "mode": "shared_write" } })) .expect("scope"); assert_eq!(scope.user_id, "user_1"); assert_eq!(scope.workspace_id, "workspace_1"); assert_eq!(scope.session_id, "session_1"); assert_eq!(scope.source_kind, WorkspaceSourceKind::LocalFolder); assert_eq!(scope.permission_level, AiAccessPermissionLevel::ReadWrite); assert_eq!(scope.allowed_roots.len(), 1); assert_eq!(scope.allowed_file_paths.len(), 1); assert_eq!(scope.allowed_resource_ids, vec!["local-md:README.md"]); assert_eq!( scope .share_context .as_ref() .and_then(|value| value.share_id.as_deref()), Some("share_1") ); let serialized = serde_json::to_value(&scope).expect("serialized"); assert_eq!(serialized["userId"], "user_1"); assert_eq!(serialized["permissionLevel"], "read_write"); assert_eq!( serialized["allowedRoots"][0], "file:///mnt/Data1T/Mnote_data/users/user_1" ); } #[test] fn ai_access_scope_rejects_blank_required_fields_and_paths() { let error = serde_json::from_value::(json!({ "userId": " ", "workspaceId": "workspace_1", "sessionId": "session_1", "sourceKind": "local_folder", "permissionLevel": "read_only", "allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"], "allowedFilePaths": [], "allowedResourceIds": [] })) .expect_err("blank user id should be rejected"); assert!(error.to_string().contains("userId")); let error = serde_json::from_value::(json!({ "userId": "user_1", "workspaceId": "workspace_1", "sessionId": "session_1", "sourceKind": "local_folder", "permissionLevel": "read_only", "allowedRoots": [" "], "allowedFilePaths": [], "allowedResourceIds": [] })) .expect_err("blank allowed root should be rejected"); assert!(error.to_string().contains("allowedRoots")); } }