diff --git a/Cargo.lock b/Cargo.lock index 77791b147..255cbc93f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -535,6 +535,7 @@ dependencies = [ "dashmap", "futures-util", "getrandom 0.2.17", + "hmac 0.12.1", "prost", "regex", "reqwest", @@ -542,6 +543,7 @@ dependencies = [ "rustls-native-certs", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.26.2", diff --git a/Cargo.toml b/Cargo.toml index 1454951a1..b05d82e75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,6 +159,7 @@ calamine = "0.36" rust_xlsxwriter = "0.82" sha1 = "0.10" sha2 = "0.10" +hmac = "0.12" # AWS aws-config = { version = "1", features = ["behavior-version-latest"] } diff --git a/crates/aionui-api-types/src/channel.rs b/crates/aionui-api-types/src/channel.rs index a403bf36f..d98ad2142 100644 --- a/crates/aionui-api-types/src/channel.rs +++ b/crates/aionui-api-types/src/channel.rs @@ -53,14 +53,26 @@ pub struct TestPluginExtraConfig { /// Request body for `POST /api/channel/pairings/approve`. #[derive(Debug, Deserialize)] +/// Identifies the pairing request either by the transient plaintext `code` +/// (from the `channel.pairing-requested` event or manual entry) or by the +/// stored request `id` (from the cold-loaded pending list). Exactly one +/// should be supplied; `id` wins when both are present. pub struct ApprovePairingRequest { - pub code: String, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub id: Option, } /// Request body for `POST /api/channel/pairings/reject`. +/// +/// Same selector semantics as [`ApprovePairingRequest`]. #[derive(Debug, Deserialize)] pub struct RejectPairingRequest { - pub code: String, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub id: Option, } // --------------------------------------------------------------------------- @@ -144,7 +156,11 @@ pub struct ChannelPlatformSettingsResponse { /// Excludes encrypted config data for security. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PluginStatusResponse { + /// Platform key — the UI's stable plugin addressing (e.g. "telegram"). pub plugin_id: String, + /// Connection identity behind this plugin entry (channel refactor A1). + #[serde(default)] + pub connection_id: String, #[serde(rename = "type")] pub plugin_type: String, pub name: String, @@ -194,7 +210,11 @@ pub struct BridgeResponse { /// Corresponds to `IChannelPairingRequest`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PairingRequestResponse { - pub code: String, + /// Stored request id — the stable approve/reject selector. The plaintext + /// code is not persisted (only its server-side hash) and therefore cannot + /// appear in cold-loaded listings; it travels only in the transient + /// `channel.pairing-requested` event. + pub id: String, pub platform_user_id: String, pub platform_type: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -233,10 +253,17 @@ pub struct ChannelUserResponse { pub struct ChannelSessionResponse { pub id: String, pub user_id: String, - pub agent_type: String, + /// Deprecated — always `None` since the channel refactor moved agent + /// configuration out of the session onto channel settings + the + /// conversation snapshot. Kept (and omitted from the payload when + /// absent) so existing clients keep deserializing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] + /// Deprecated — always `None`; the session workspace column never had a + /// production reader and was dropped with the same refactor. + #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace: Option, #[serde(skip_serializing_if = "Option::is_none")] pub chat_id: Option, @@ -255,6 +282,9 @@ pub struct ChannelSessionResponse { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PairingRequestedPayload { pub user_id: String, + /// Stored request id (stable approve/reject selector). + pub id: String, + /// Transient plaintext code — shown once; never persisted server-side. pub code: String, pub platform_user_id: String, pub platform_type: String, @@ -395,21 +425,43 @@ mod tests { fn test_approve_pairing_request_deserialize() { let raw = json!({ "code": "123456" }); let req: ApprovePairingRequest = serde_json::from_value(raw).unwrap(); - assert_eq!(req.code, "123456"); + assert_eq!(req.code.as_deref(), Some("123456")); + assert_eq!(req.id, None); + } + + #[test] + fn test_approve_pairing_request_accepts_id_selector() { + let raw = json!({ "id": "pair-1" }); + let req: ApprovePairingRequest = serde_json::from_value(raw).unwrap(); + assert_eq!(req.id.as_deref(), Some("pair-1")); + assert_eq!(req.code, None); } + /// Both selectors are optional at the DTO layer — an empty body + /// deserializes, and the route rejects it with a 400 (see + /// `pairing_selector` in the channel routes). #[test] - fn test_approve_pairing_request_missing_code() { + fn test_approve_pairing_request_empty_body_leaves_both_selectors_none() { let raw = json!({}); - let result = serde_json::from_value::(raw); - assert!(result.is_err()); + let req: ApprovePairingRequest = serde_json::from_value(raw).unwrap(); + assert_eq!(req.code, None); + assert_eq!(req.id, None); } #[test] fn test_reject_pairing_request_deserialize() { let raw = json!({ "code": "654321" }); let req: RejectPairingRequest = serde_json::from_value(raw).unwrap(); - assert_eq!(req.code, "654321"); + assert_eq!(req.code.as_deref(), Some("654321")); + assert_eq!(req.id, None); + } + + #[test] + fn test_reject_pairing_request_accepts_id_selector() { + let raw = json!({ "id": "pair-9" }); + let req: RejectPairingRequest = serde_json::from_value(raw).unwrap(); + assert_eq!(req.id.as_deref(), Some("pair-9")); + assert_eq!(req.code, None); } // -- C. User management requests ------------------------------------------ @@ -450,6 +502,7 @@ mod tests { fn test_plugin_status_response_serde() { let resp = PluginStatusResponse { plugin_id: "telegram".into(), + connection_id: String::new(), plugin_type: "telegram".into(), name: "Telegram Bot".into(), enabled: true, @@ -481,6 +534,7 @@ mod tests { fn test_plugin_status_response_optional_fields_omitted() { let resp = PluginStatusResponse { plugin_id: "lark".into(), + connection_id: String::new(), plugin_type: "lark".into(), name: "Lark Bot".into(), enabled: false, @@ -573,7 +627,7 @@ mod tests { #[test] fn test_pairing_request_response_serde() { let resp = PairingRequestResponse { - code: "123456".into(), + id: "pair-1".into(), platform_user_id: "tg_user_42".into(), platform_type: "telegram".into(), display_name: Some("Alice".into()), @@ -581,7 +635,10 @@ mod tests { expires_at: 1700000600000, }; let json = serde_json::to_value(&resp).unwrap(); - assert_eq!(json["code"], "123456"); + assert_eq!(json["id"], "pair-1"); + // The plaintext code is transient and must never appear in the + // cold-loaded pending list. + assert!(json.get("code").is_none()); assert_eq!(json["platform_user_id"], "tg_user_42"); assert_eq!(json["platform_type"], "telegram"); assert_eq!(json["display_name"], "Alice"); @@ -592,7 +649,7 @@ mod tests { #[test] fn test_pairing_request_response_no_display_name() { let resp = PairingRequestResponse { - code: "999999".into(), + id: "pair-2".into(), platform_user_id: "user_1".into(), platform_type: "lark".into(), display_name: None, @@ -646,7 +703,7 @@ mod tests { let resp = ChannelSessionResponse { id: "sess_1".into(), user_id: "usr_1".into(), - agent_type: "gemini".into(), + agent_type: Some("gemini".into()), conversation_id: Some("conv_abc".into()), workspace: Some("/workspace".into()), chat_id: Some("chat_123".into()), @@ -669,7 +726,7 @@ mod tests { let resp = ChannelSessionResponse { id: "sess_2".into(), user_id: "usr_2".into(), - agent_type: "acp".into(), + agent_type: None, conversation_id: None, workspace: None, chat_id: None, @@ -680,6 +737,26 @@ mod tests { assert!(json.get("conversation_id").is_none()); assert!(json.get("workspace").is_none()); assert!(json.get("chat_id").is_none()); + // Deprecated fields drop out of the payload rather than serializing null. + assert!(json.get("agent_type").is_none()); + } + + /// A payload without the deprecated fields — what the server now emits — + /// must still deserialize for clients round-tripping the DTO. + #[test] + fn test_channel_session_response_deserializes_without_deprecated_fields() { + let resp: ChannelSessionResponse = serde_json::from_value(serde_json::json!({ + "id": "sess_3", + "user_id": "usr_3", + "chat_id": "chat_3", + "created_at": 1700000000000_i64, + "last_activity": 1700000000000_i64, + })) + .unwrap(); + assert_eq!(resp.id, "sess_3"); + assert_eq!(resp.chat_id.as_deref(), Some("chat_3")); + assert!(resp.agent_type.is_none()); + assert!(resp.workspace.is_none()); } // -- I. WebSocket event payloads ------------------------------------------ @@ -688,6 +765,7 @@ mod tests { fn test_pairing_requested_payload_serde() { let payload = PairingRequestedPayload { user_id: "user-1".into(), + id: "pair-1".into(), code: "123456".into(), platform_user_id: "tg_42".into(), platform_type: "telegram".into(), @@ -696,6 +774,8 @@ mod tests { }; let json = serde_json::to_value(&payload).unwrap(); assert_eq!(json["user_id"], "user-1"); + // The event carries both the transient code and the addressable id. + assert_eq!(json["id"], "pair-1"); assert_eq!(json["code"], "123456"); assert_eq!(json["platform_user_id"], "tg_42"); assert_eq!(json["platform_type"], "telegram"); @@ -707,6 +787,7 @@ mod tests { fn test_pairing_requested_payload_no_display_name() { let payload = PairingRequestedPayload { user_id: "user-1".into(), + id: "pair-2".into(), code: "000001".into(), platform_user_id: "u1".into(), platform_type: "dingtalk".into(), @@ -724,6 +805,7 @@ mod tests { plugin_id: "telegram".into(), status: PluginStatusResponse { plugin_id: "telegram".into(), + connection_id: String::new(), plugin_type: "telegram".into(), name: "Telegram Bot".into(), enabled: true, @@ -781,6 +863,7 @@ mod tests { fn test_plugin_status_response_roundtrip() { let resp = PluginStatusResponse { plugin_id: "dingtalk".into(), + connection_id: String::new(), plugin_type: "dingtalk".into(), name: "DingTalk Bot".into(), enabled: true, @@ -815,7 +898,7 @@ mod tests { let resp = ChannelSessionResponse { id: "s1".into(), user_id: "u1".into(), - agent_type: "acp".into(), + agent_type: None, conversation_id: Some("c1".into()), workspace: None, chat_id: Some("ch1".into()), diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 60b396121..e330c91af 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -410,14 +410,14 @@ pub fn build_system_state(services: &AppServices) -> SystemRouterState { let client_pref_repo = Arc::new(SqliteClientPreferenceRepository::new(pool.clone())); let keep_awake_controller = Arc::new(aionui_system::SystemKeepAwakeController::new()); - let client_pref_service = if services.identity_mode.is_local() { - ClientPrefService::with_keep_awake_controller(client_pref_repo, keep_awake_controller, "system_default_user") - } else { - ClientPrefService::with_keep_awake_controller_without_restore(client_pref_repo, keep_awake_controller) - }; + // `keepAwake` is device-scoped (migration 031 segment): one value for the + // machine, owned by no account. Restoring it at startup is therefore + // correct in both identity modes — there is no per-user value to leak. + let client_pref_service = + ClientPrefService::with_keep_awake_controller(client_pref_repo.clone(), keep_awake_controller); SystemRouterState { - settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(pool.clone()))), + settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(pool.clone())), client_pref_repo), client_pref_service, provider_service: ProviderService::new(provider_repo.clone(), encryption_key), model_fetch_service: ModelFetchService::new(provider_repo, encryption_key, http_client.clone()), @@ -552,6 +552,10 @@ fn build_channel_settings_service( Arc::new(SqliteClientPreferenceRepository::new(services.database.pool().clone())); let mut service = aionui_channel::channel_settings::ChannelSettingsService::new(pref_repo) + // Connection-scoped preference keys (channel refactor A4). + .with_channel_repo(Arc::new(aionui_db::SqliteChannelRepository::new( + services.database.pool().clone(), + ))) .with_agent_metadata_repo(Arc::new(SqliteAgentMetadataRepository::new( services.database.pool().clone(), ))) @@ -617,9 +621,12 @@ pub async fn build_channel_state( confirm_tx, )); + // The pairing-code HMAC shares the channel credential key: both protect + // channel secrets at rest and rotate together with the JWT secret. let pairing_service = Arc::new(aionui_channel::pairing::PairingService::new( repo.clone(), services.event_bus.clone(), + encryption_key, )); let session_manager = Arc::new(aionui_channel::session::SessionManager::new(repo.clone())); @@ -996,7 +1003,7 @@ mod tests { use aionui_api_types::{CreateConversationRequest, SendMessageRequest}; use aionui_channel::types::PluginType; use aionui_common::{AgentKillReason, AgentType, ConversationStatus, TimestampMs}; - use aionui_db::models::{AssistantSessionRow, UpsertAssistantDefinitionParams}; + use aionui_db::models::{ChannelConversationBindingRow, UpsertAssistantDefinitionParams}; use aionui_db::{ IAssistantDefinitionRepository, IClientPreferenceRepository, IConversationRepository, SqliteAssistantDefinitionRepository, SqliteClientPreferenceRepository, SqliteConversationRepository, @@ -1207,13 +1214,13 @@ mod tests { let settings = build_channel_settings_service(&services, None); let message_service = build_channel_message_service(&services, settings).await; - let session = AssistantSessionRow { + let session = ChannelConversationBindingRow { id: "session-channel-state".to_owned(), + owner_user_id: "system_default_user".to_owned(), + connection_id: "conn-channel-state".to_owned(), user_id: "channel-user-state".to_owned(), - agent_type: "aionrs".to_owned(), - conversation_id: None, - workspace: None, chat_id: Some("wx-chat-state".to_owned()), + conversation_id: None, created_at: 1, last_activity: 1, }; @@ -1245,7 +1252,7 @@ mod tests { assert_eq!(conversation.r#type, AgentType::Aionrs.serde_name()); assert_eq!(conversation.name, "Weixin Aionrs"); - let second_session = AssistantSessionRow { + let second_session = ChannelConversationBindingRow { conversation_id: Some(first.conversation_id.clone()), ..session }; diff --git a/crates/aionui-app/tests/channel_e2e.rs b/crates/aionui-app/tests/channel_e2e.rs index efd730c32..0b4aeb748 100644 --- a/crates/aionui-app/tests/channel_e2e.rs +++ b/crates/aionui-app/tests/channel_e2e.rs @@ -6,7 +6,7 @@ mod common; use aionui_common::now_ms; -use aionui_db::models::{AssistantSessionRow, AssistantUserRow}; +use aionui_db::models::{ChannelConnectionRow, ChannelConversationBindingRow, ChannelUserRow}; use aionui_db::{IChannelRepository, SqliteChannelRepository}; use axum::http::StatusCode; use serde_json::json; @@ -184,6 +184,31 @@ async fn test_plugin_missing_token() { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } +/// Seeds the connection a channel user or pairing request attaches to, +/// returning its connection id. +async fn seed_connection(repo: &std::sync::Arc, plugin_key: &str) -> String { + let now = now_ms(); + let id = format!("conn-{plugin_key}"); + repo.upsert_connection( + OWNER_ID, + &ChannelConnectionRow { + id: id.clone(), + owner_user_id: OWNER_ID.to_owned(), + plugin_key: plugin_key.to_owned(), + name: format!("{plugin_key} bot"), + enabled: true, + config: "{}".to_owned(), + status: None, + last_connected: None, + created_at: now, + updated_at: now, + }, + ) + .await + .unwrap(); + id +} + // =========================================================================== // §2 Pairing management // =========================================================================== @@ -314,6 +339,73 @@ async fn get_sessions_empty() { assert!(json["data"].as_array().unwrap().is_empty()); } +// GS-2: A populated session response carries the binding fields, and the +// deprecated agent_type/workspace fields are omitted rather than serialized. +#[tokio::test] +async fn get_sessions_returns_binding_without_deprecated_agent_fields() { + let (mut app, services) = build_app().await; + let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let repo: std::sync::Arc = + std::sync::Arc::new(SqliteChannelRepository::new(services.database.pool().clone())); + + let now = now_ms(); + let connection_id = seed_connection(&repo, "telegram").await; + repo.create_user( + OWNER_ID, + &ChannelUserRow { + id: "cu-sessions".to_owned(), + owner_user_id: OWNER_ID.to_owned(), + connection_id, + platform_user_id: "tg-sessions".to_owned(), + platform_type: "telegram".to_owned(), + display_name: Some("Sessions User".to_owned()), + status: "active".to_owned(), + revoked_at: None, + authorized_at: now, + last_active: None, + }, + ) + .await + .unwrap(); + repo.get_or_create_session( + OWNER_ID, + "cu-sessions", + "chat-sessions", + &ChannelConversationBindingRow { + id: "cs-sessions".to_owned(), + owner_user_id: String::new(), + connection_id: String::new(), + user_id: "cu-sessions".to_owned(), + conversation_id: None, + chat_id: Some("chat-sessions".to_owned()), + created_at: now, + last_activity: now, + }, + ) + .await + .unwrap(); + + let req = get_with_token("/api/channel/sessions", &token); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let json = body_json(resp).await; + let sessions = json["data"].as_array().unwrap(); + assert_eq!(sessions.len(), 1); + let session = &sessions[0]; + assert_eq!(session["id"], "cs-sessions"); + assert_eq!(session["user_id"], "cu-sessions"); + assert_eq!(session["chat_id"], "chat-sessions"); + assert!( + session.get("agent_type").is_none(), + "deprecated agent_type must be omitted, got: {session}" + ); + assert!( + session.get("workspace").is_none(), + "deprecated workspace must be omitted, got: {session}" + ); +} + // =========================================================================== // §5 Settings sync // =========================================================================== @@ -417,27 +509,32 @@ async fn put_channel_assistant_setting_clears_active_sessions() { std::sync::Arc::new(SqliteChannelRepository::new(services.database.pool().clone())); let now = now_ms(); + let connection_id = seed_connection(&repo, "lark").await; repo.create_user( OWNER_ID, - &AssistantUserRow { + &ChannelUserRow { id: "user-channel-assistant".to_owned(), owner_user_id: OWNER_ID.to_owned(), + connection_id, platform_user_id: "user-channel-assistant".to_owned(), platform_type: "lark".to_owned(), display_name: Some("Channel Assistant User".to_owned()), + status: "active".to_owned(), + revoked_at: None, authorized_at: now, last_active: Some(now), - session_id: None, }, ) .await .unwrap(); - let new_session = AssistantSessionRow { + let new_session = ChannelConversationBindingRow { id: "sess-channel-assistant".to_owned(), + // Owner and connection are derived by the repository from the + // active channel user; the caller leaves them empty. + owner_user_id: String::new(), + connection_id: String::new(), user_id: "user-channel-assistant".to_owned(), - agent_type: "acp".to_owned(), conversation_id: None, - workspace: None, chat_id: Some("chat-channel-assistant".to_owned()), created_at: now, last_activity: now, @@ -475,27 +572,32 @@ async fn put_channel_default_model_setting_clears_active_sessions() { std::sync::Arc::new(SqliteChannelRepository::new(services.database.pool().clone())); let now = now_ms(); + let connection_id = seed_connection(&repo, "lark").await; repo.create_user( OWNER_ID, - &AssistantUserRow { + &ChannelUserRow { id: "user-channel-model".to_owned(), owner_user_id: OWNER_ID.to_owned(), + connection_id, platform_user_id: "user-channel-model".to_owned(), platform_type: "lark".to_owned(), display_name: Some("Channel Model User".to_owned()), + status: "active".to_owned(), + revoked_at: None, authorized_at: now, last_active: Some(now), - session_id: None, }, ) .await .unwrap(); - let new_session = AssistantSessionRow { + let new_session = ChannelConversationBindingRow { id: "sess-channel-model".to_owned(), + // Owner and connection are derived by the repository from the + // active channel user; the caller leaves them empty. + owner_user_id: String::new(), + connection_id: String::new(), user_id: "user-channel-model".to_owned(), - agent_type: "acp".to_owned(), conversation_id: None, - workspace: None, chat_id: Some("chat-channel-model".to_owned()), created_at: now, last_activity: now, @@ -585,7 +687,14 @@ async fn pairing_approve_creates_user() { let pool = services.database.pool().clone(); let repo: std::sync::Arc = std::sync::Arc::new(aionui_db::SqliteChannelRepository::new(pool)); - let pairing_svc = aionui_channel::pairing::PairingService::new(repo.clone(), services.event_bus.clone()); + // The route's pairing service hashes codes with the app's channel key; + // this one must share it so a code minted here approves over HTTP. + let pairing_svc = aionui_channel::pairing::PairingService::new( + repo.clone(), + services.event_bus.clone(), + aionui_app::derive_encryption_key(&services.jwt_secret_raw), + ); + seed_connection(&repo, "telegram").await; let code = pairing_svc .request_pairing(OWNER_ID, "tg_user_42", "telegram", Some("Alice")) @@ -599,7 +708,10 @@ async fn pairing_approve_creates_user() { let json = body_json(resp).await; let pairings = json["data"].as_array().unwrap(); assert_eq!(pairings.len(), 1); - assert_eq!(pairings[0]["code"], code); + // The cold-loaded list exposes the addressable id, never the code. + let pairing_id = pairings[0]["id"].as_str().unwrap().to_owned(); + assert!(!pairing_id.is_empty()); + assert!(pairings[0].get("code").is_none()); assert_eq!(pairings[0]["platform_user_id"], "tg_user_42"); assert_eq!(pairings[0]["platform_type"], "telegram"); assert_eq!(pairings[0]["display_name"], "Alice"); @@ -629,7 +741,10 @@ async fn pairing_approve_creates_user() { assert_eq!(users[0]["display_name"], "Alice"); let user_id = users[0]["id"].as_str().unwrap().to_owned(); - // Verify double-approve fails + // Verify double-approve fails. A used code is no longer resolvable — + // the hash lookup only matches pending requests — so replaying it is a + // 404, while addressing the same request by id reports it as already + // processed. let req = json_with_token( "POST", "/api/channel/pairings/approve", @@ -638,6 +753,16 @@ async fn pairing_approve_creates_user() { &csrf, ); let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + let req = json_with_token( + "POST", + "/api/channel/pairings/approve", + json!({ "id": pairing_id }), + &token, + &csrf, + ); + let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); // Pairing should no longer appear in pending list @@ -664,6 +789,16 @@ async fn pairing_approve_creates_user() { let resp = app.clone().oneshot(req).await.unwrap(); let json = body_json(resp).await; assert!(json["data"].as_array().unwrap().is_empty()); + + // Revocation is a soft delete: the authorization history survives. + let (status, revoked_at): (String, Option) = + sqlx::query_as("SELECT status, revoked_at FROM channel_users WHERE id = ?") + .bind(&user_id) + .fetch_one(services.database.pool()) + .await + .unwrap(); + assert_eq!(status, "revoked"); + assert!(revoked_at.is_some()); } /// Test pairing rejection flow. @@ -676,7 +811,12 @@ async fn pairing_reject_removes_from_pending() { let pool = services.database.pool().clone(); let repo: std::sync::Arc = std::sync::Arc::new(aionui_db::SqliteChannelRepository::new(pool)); - let pairing_svc = aionui_channel::pairing::PairingService::new(repo.clone(), services.event_bus.clone()); + let pairing_svc = aionui_channel::pairing::PairingService::new( + repo.clone(), + services.event_bus.clone(), + aionui_app::derive_encryption_key(&services.jwt_secret_raw), + ); + seed_connection(&repo, "telegram").await; let code = pairing_svc .request_pairing(OWNER_ID, "tg_user_99", "telegram", None) @@ -708,7 +848,8 @@ async fn pairing_reject_removes_from_pending() { let json = body_json(resp).await; assert!(json["data"].as_array().unwrap().is_empty()); - // Verify reject same code again fails (already processed) + // Verify rejecting the same code again fails: the rejected request is + // no longer pending, so its code hash resolves to nothing (404). let req = json_with_token( "POST", "/api/channel/pairings/reject", @@ -717,7 +858,7 @@ async fn pairing_reject_removes_from_pending() { &csrf, ); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } // =========================================================================== diff --git a/crates/aionui-app/tests/extension_e2e.rs b/crates/aionui-app/tests/extension_e2e.rs index 63ee5c00a..96b1cf145 100644 --- a/crates/aionui-app/tests/extension_e2e.rs +++ b/crates/aionui-app/tests/extension_e2e.rs @@ -621,12 +621,12 @@ async fn eq19_channel_status_merges_extension_meta_for_persisted_row() { let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass1").await; let owner_user_id = services.user_repo.find_by_username("user1").await.unwrap().unwrap().id; let now = now_ms(); - repo.upsert_plugin( + repo.upsert_connection( &owner_user_id, - &aionui_db::models::ChannelPluginRow { + &aionui_db::models::ChannelConnectionRow { id: "legacy-channel".to_string(), owner_user_id: owner_user_id.clone(), - r#type: "legacy-channel".to_string(), + plugin_key: "legacy-channel".to_string(), name: "Legacy Channel Persisted".to_string(), enabled: true, config: "{\"token\":\"secret\"}".to_string(), @@ -694,12 +694,12 @@ async fn eq20_enable_extension_channel_persists_config_and_exposes_status() { assert_eq!(enable_json["data"]["success"], true); let row = repo - .get_plugin(&owner_user_id, "legacy-channel") + .get_connection_by_plugin_key(&owner_user_id, "legacy-channel") .await .unwrap() .unwrap(); assert!(row.enabled); - assert_eq!(row.r#type, "legacy-channel"); + assert_eq!(row.plugin_key, "legacy-channel"); assert_eq!(row.status.as_deref(), Some("stopped")); let encryption_key = derive_encryption_key(&services.jwt_secret_raw); diff --git a/crates/aionui-app/tests/session_revoke_cleanup_e2e.rs b/crates/aionui-app/tests/session_revoke_cleanup_e2e.rs index d7bfde3df..7756bb3a1 100644 --- a/crates/aionui-app/tests/session_revoke_cleanup_e2e.rs +++ b/crates/aionui-app/tests/session_revoke_cleanup_e2e.rs @@ -6,7 +6,7 @@ //! services — revokes an AionPro session over HTTP, and asserts observable //! cleanup actually happened: //! -//! - channel sessions: the user's `assistant_sessions` rows are deleted by +//! - channel sessions: the user's `channel_conversation_bindings` rows are deleted by //! `ChannelSessionManager::clear_all_sessions` (async part of the hook, //! polled with a timeout); //! - session invalidation: the revoked cookie token stops working @@ -22,7 +22,7 @@ use axum::http::{Request, StatusCode, header}; use http_body_util::BodyExt; use tower::ServiceExt; -use aionui_db::models::{AssistantSessionRow, AssistantUserRow}; +use aionui_db::models::{ChannelConnectionRow, ChannelConversationBindingRow, ChannelUserRow}; use aionui_db::{IChannelRepository, SqliteChannelRepository}; const BOOTSTRAP: &str = "bootstrap-secret"; @@ -98,21 +98,42 @@ async fn http_revoke_runs_the_real_cleanup_hook_end_to_end() { .to_owned(); // Seed observable channel state owned by that user: one channel user with - // one active channel session (assistant_sessions row). + // one active channel session (channel_conversation_bindings row). let channel_repo = SqliteChannelRepository::new(services.database.pool().clone()); let now = aionui_common::now_ms(); + // Channel users hang off the connection that authorized them. + channel_repo + .upsert_connection( + &user_id, + &ChannelConnectionRow { + id: "conn-revoke".into(), + owner_user_id: user_id.clone(), + plugin_key: "telegram".into(), + name: "TG".into(), + enabled: true, + config: "{}".into(), + status: None, + last_connected: None, + created_at: now, + updated_at: now, + }, + ) + .await + .unwrap(); channel_repo .create_user( &user_id, - &AssistantUserRow { + &ChannelUserRow { id: "cu-revoke".into(), owner_user_id: user_id.clone(), + connection_id: "conn-revoke".into(), platform_user_id: "tg-revoke".into(), platform_type: "telegram".into(), display_name: Some("TG".into()), + status: "active".into(), + revoked_at: None, authorized_at: now, last_active: None, - session_id: None, }, ) .await @@ -122,12 +143,13 @@ async fn http_revoke_runs_the_real_cleanup_hook_end_to_end() { &user_id, "cu-revoke", "chat-revoke", - &AssistantSessionRow { + &ChannelConversationBindingRow { id: "cs-revoke".into(), + // Derived by the repository from the active channel user. + owner_user_id: String::new(), + connection_id: String::new(), user_id: "cu-revoke".into(), - agent_type: "gemini".into(), conversation_id: None, - workspace: None, chat_id: Some("chat-revoke".into()), created_at: now, last_activity: now, diff --git a/crates/aionui-channel/Cargo.toml b/crates/aionui-channel/Cargo.toml index a05104794..c0d4a2a86 100644 --- a/crates/aionui-channel/Cargo.toml +++ b/crates/aionui-channel/Cargo.toml @@ -27,6 +27,8 @@ thiserror.workspace = true async-trait.workspace = true tracing.workspace = true getrandom.workspace = true +hmac.workspace = true +sha2.workspace = true dashmap.workspace = true regex.workspace = true reqwest = { workspace = true, optional = true } diff --git a/crates/aionui-channel/src/action.rs b/crates/aionui-channel/src/action.rs index 88dd85b4f..33e7483a4 100644 --- a/crates/aionui-channel/src/action.rs +++ b/crates/aionui-channel/src/action.rs @@ -102,16 +102,9 @@ impl ActionExecutor { } // 3. Text message → session resolution → AI dispatch - let agent_config = self.settings.get_agent_config(owner_user_id, msg.platform).await?; let session = self .session_mgr - .get_or_create_session( - owner_user_id, - &internal_user_id, - chat_id, - &agent_config.agent_type, - None, - ) + .get_or_create_session(owner_user_id, &internal_user_id, chat_id) .await?; info!( @@ -267,15 +260,12 @@ impl ActionExecutor { .settings .get_agent_config(owner_user_id, action.context.platform) .await?; - let session = self - .session_mgr - .reset_session(owner_user_id, user_id, chat_id, &agent_config.agent_type, None) - .await?; + let session = self.session_mgr.reset_session(owner_user_id, user_id, chat_id).await?; Ok(ActionResponse { text: Some(format!( "New session created.\nAgent: {}\nSession: {}", - session.agent_type, + agent_config.agent_type, &session.id[..8] )), parse_mode: None, @@ -299,14 +289,14 @@ impl ActionExecutor { .await?; let session = self .session_mgr - .get_or_create_session(owner_user_id, user_id, chat_id, &agent_config.agent_type, None) + .get_or_create_session(owner_user_id, user_id, chat_id) .await?; Ok(ActionResponse { text: Some(format!( "Session: {}\nAgent: {}\nCreated: {}\nLast active: {}", &session.id[..8], - session.agent_type, + agent_config.agent_type, session.created_at, session.last_activity, )), @@ -542,9 +532,9 @@ mod tests { use aionui_api_types::WebSocketMessage; use aionui_common::{TimestampMs, now_ms}; use aionui_db::models::{ - AssistantSessionRow, AssistantUserRow, ChannelPluginRow, ClientPreference, PairingCodeRow, + ChannelConnectionRow, ChannelConversationBindingRow, ChannelPairingRequestRow, ChannelUserRow, ClientPreference, }; - use aionui_db::{DbError, IChannelRepository, IClientPreferenceRepository, UpdatePluginStatusParams}; + use aionui_db::{DbError, IChannelRepository, IClientPreferenceRepository, UpdateConnectionStatusParams}; use aionui_realtime::EventBroadcaster; use std::collections::HashMap; use std::sync::Mutex; @@ -561,14 +551,35 @@ mod tests { const OWNER_ID: &str = "owner-test"; struct MockRepo { - users: Mutex>, - sessions: Mutex>, - pairings: Mutex>, + connections: Mutex>, + users: Mutex>, + sessions: Mutex>, + pairings: Mutex>, } impl MockRepo { + /// Starts with one connection per platform the tests speak on — + /// pairing resolves a connection by plugin key and refuses + /// platforms that have none. fn new() -> Self { + let connections = ["telegram", "lark", "dingtalk", "weixin", "slack", "discord"] + .into_iter() + .map(|plugin_key| ChannelConnectionRow { + id: connection_id_for(plugin_key), + owner_user_id: OWNER_ID.to_owned(), + plugin_key: plugin_key.to_owned(), + name: format!("{plugin_key} bot"), + enabled: true, + config: "{}".into(), + status: None, + last_connected: None, + created_at: 0, + updated_at: 0, + }) + .collect(); + Self { + connections: Mutex::new(connections), users: Mutex::new(Vec::new()), sessions: Mutex::new(Vec::new()), pairings: Mutex::new(Vec::new()), @@ -576,61 +587,128 @@ mod tests { } fn add_authorized_user(&self, platform_user_id: &str, platform_type: &str) { - let user = AssistantUserRow { + let user = ChannelUserRow { id: format!("user_{platform_user_id}"), owner_user_id: OWNER_ID.to_owned(), + connection_id: connection_id_for(platform_type), platform_user_id: platform_user_id.to_owned(), platform_type: platform_type.to_owned(), display_name: Some("Test User".into()), + status: "active".into(), + revoked_at: None, authorized_at: now_ms(), last_active: None, - session_id: None, }; self.users.lock().unwrap().push(user); } + + /// Resolves the platform of a row through its connection, the way + /// the SQL implementation's JOIN does. + fn platform_of(&self, connection_id: &str) -> String { + self.connections + .lock() + .unwrap() + .iter() + .find(|c| c.id == connection_id) + .map(|c| c.plugin_key.clone()) + .unwrap_or_default() + } + } + + fn connection_id_for(plugin_key: &str) -> String { + format!("conn-{plugin_key}") } #[async_trait::async_trait] impl IChannelRepository for MockRepo { - async fn get_all_plugins(&self, _owner_user_id: &str) -> Result, DbError> { - Ok(vec![]) + async fn get_all_connections(&self, _owner_user_id: &str) -> Result, DbError> { + Ok(self.connections.lock().unwrap().clone()) + } + async fn get_connection( + &self, + _owner_user_id: &str, + id: &str, + ) -> Result, DbError> { + Ok(self.connections.lock().unwrap().iter().find(|c| c.id == id).cloned()) } - async fn get_plugin(&self, _owner_user_id: &str, _id: &str) -> Result, DbError> { - Ok(None) + + async fn get_connection_by_plugin_key( + &self, + _owner_user_id: &str, + plugin_key: &str, + ) -> Result, DbError> { + Ok(self + .connections + .lock() + .unwrap() + .iter() + .find(|c| c.plugin_key == plugin_key) + .cloned()) } - async fn upsert_plugin(&self, _owner_user_id: &str, _row: &ChannelPluginRow) -> Result<(), DbError> { + async fn upsert_connection(&self, _owner_user_id: &str, row: &ChannelConnectionRow) -> Result<(), DbError> { + let mut connections = self.connections.lock().unwrap(); + connections.retain(|c| c.id != row.id); + connections.push(row.clone()); Ok(()) } - async fn update_plugin_status( + async fn update_connection_status( &self, _owner_user_id: &str, _id: &str, - _params: &UpdatePluginStatusParams, + _params: &UpdateConnectionStatusParams, ) -> Result<(), DbError> { Ok(()) } - async fn delete_plugin(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { + async fn delete_connection(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { Ok(()) } - async fn get_all_users(&self, _owner_user_id: &str) -> Result, DbError> { - Ok(self.users.lock().unwrap().clone()) + async fn get_all_users(&self, _owner_user_id: &str) -> Result, DbError> { + Ok(self + .users + .lock() + .unwrap() + .iter() + .filter(|u| u.status == "active") + .cloned() + .collect()) } async fn get_user_by_platform( &self, _owner_user_id: &str, platform_user_id: &str, platform_type: &str, - ) -> Result, DbError> { + ) -> Result, DbError> { let users = self.users.lock().unwrap(); Ok(users .iter() - .find(|u| u.platform_user_id == platform_user_id && u.platform_type == platform_type) + .find(|u| { + u.status == "active" + && u.platform_user_id == platform_user_id + && self.platform_of(&u.connection_id) == platform_type + }) .cloned()) } - async fn create_user(&self, _owner_user_id: &str, row: &AssistantUserRow) -> Result<(), DbError> { - self.users.lock().unwrap().push(row.clone()); - Ok(()) + async fn create_user(&self, _owner_user_id: &str, row: &ChannelUserRow) -> Result<(), DbError> { + let mut users = self.users.lock().unwrap(); + let existing = users + .iter_mut() + .find(|u| u.connection_id == row.connection_id && u.platform_user_id == row.platform_user_id); + match existing { + Some(u) if u.status == "active" => Err(DbError::Conflict("user already exists".into())), + Some(u) => { + // Reactivate the revoked authorization in place. + u.status = "active".into(); + u.revoked_at = None; + u.display_name = row.display_name.clone(); + u.authorized_at = row.authorized_at; + Ok(()) + } + None => { + users.push(row.clone()); + Ok(()) + } + } } async fn update_user_last_active( &self, @@ -640,24 +718,49 @@ mod tests { ) -> Result<(), DbError> { Ok(()) } - async fn delete_user(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { - Ok(()) + async fn revoke_user(&self, _owner_user_id: &str, id: &str) -> Result<(), DbError> { + let mut users = self.users.lock().unwrap(); + match users.iter_mut().find(|u| u.id == id && u.status == "active") { + Some(u) => { + // Soft delete: the audit row stays, marked revoked. + u.status = "revoked".into(); + u.revoked_at = Some(now_ms()); + self.sessions.lock().unwrap().retain(|s| s.user_id != id); + Ok(()) + } + None => Err(DbError::NotFound(id.into())), + } } - async fn get_all_sessions(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_all_sessions(&self, _owner_user_id: &str) -> Result, DbError> { Ok(self.sessions.lock().unwrap().clone()) } - async fn get_session(&self, _owner_user_id: &str, id: &str) -> Result, DbError> { + async fn get_session( + &self, + _owner_user_id: &str, + id: &str, + ) -> Result, DbError> { let sessions = self.sessions.lock().unwrap(); Ok(sessions.iter().find(|s| s.id == id).cloned()) } async fn get_or_create_session( &self, - _owner_user_id: &str, + owner_user_id: &str, user_id: &str, chat_id: &str, - new_row: &AssistantSessionRow, - ) -> Result { + new_row: &ChannelConversationBindingRow, + ) -> Result { + // Mirror the SQL INSERT: owner/connection are derived from the + // ACTIVE channel user, so an unknown or revoked user gets nothing. + let connection_id = self + .users + .lock() + .unwrap() + .iter() + .find(|u| u.id == user_id && u.owner_user_id == owner_user_id && u.status == "active") + .map(|u| u.connection_id.clone()) + .ok_or_else(|| DbError::NotFound(format!("Channel user '{user_id}' not found")))?; + let mut sessions = self.sessions.lock().unwrap(); if let Some(existing) = sessions .iter_mut() @@ -666,8 +769,13 @@ mod tests { existing.last_activity = new_row.last_activity; return Ok(existing.clone()); } - sessions.push(new_row.clone()); - Ok(new_row.clone()) + let created = ChannelConversationBindingRow { + owner_user_id: owner_user_id.to_owned(), + connection_id, + ..new_row.clone() + }; + sessions.push(created.clone()); + Ok(created) } async fn update_session_activity( &self, @@ -691,20 +799,6 @@ mod tests { Err(DbError::NotFound(id.into())) } } - async fn update_session_agent_type( - &self, - _owner_user_id: &str, - id: &str, - agent_type: &str, - ) -> Result<(), DbError> { - let mut sessions = self.sessions.lock().unwrap(); - if let Some(s) = sessions.iter_mut().find(|s| s.id == id) { - s.agent_type = agent_type.to_owned(); - Ok(()) - } else { - Err(DbError::NotFound(id.into())) - } - } async fn delete_sessions_by_user(&self, _owner_user_id: &str, user_id: &str) -> Result<(), DbError> { self.sessions.lock().unwrap().retain(|s| s.user_id != user_id); Ok(()) @@ -720,30 +814,76 @@ mod tests { Ok(()) } - async fn create_pairing(&self, _owner_user_id: &str, row: &PairingCodeRow) -> Result<(), DbError> { - self.pairings.lock().unwrap().push(row.clone()); + async fn create_pairing(&self, _owner_user_id: &str, row: &ChannelPairingRequestRow) -> Result<(), DbError> { + let mut pairings = self.pairings.lock().unwrap(); + // Mirrors the partial unique indexes: one pending request per + // (connection, external user) and per code hash. + if pairings.iter().any(|p| { + p.status == "pending" + && (p.code_hash == row.code_hash + || (p.connection_id == row.connection_id && p.platform_user_id == row.platform_user_id)) + }) { + return Err(DbError::Conflict("duplicate pending pairing request".into())); + } + pairings.push(row.clone()); Ok(()) } - async fn get_pending_pairings(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_pending_pairings(&self, _owner_user_id: &str) -> Result, DbError> { let pairings = self.pairings.lock().unwrap(); Ok(pairings.iter().filter(|p| p.status == "pending").cloned().collect()) } - async fn get_pairing_by_code( + async fn get_pairing( + &self, + _owner_user_id: &str, + id: &str, + ) -> Result, DbError> { + let pairings = self.pairings.lock().unwrap(); + Ok(pairings.iter().find(|p| p.id == id).cloned()) + } + async fn get_pending_pairing_by_code_hash( &self, _owner_user_id: &str, - code: &str, - ) -> Result, DbError> { + code_hash: &str, + ) -> Result, DbError> { let pairings = self.pairings.lock().unwrap(); - Ok(pairings.iter().find(|p| p.code == code).cloned()) + Ok(pairings + .iter() + .find(|p| p.code_hash == code_hash && p.status == "pending") + .cloned()) } - async fn update_pairing_status(&self, _owner_user_id: &str, code: &str, status: &str) -> Result<(), DbError> { + async fn update_pairing_status( + &self, + _owner_user_id: &str, + id: &str, + status: &str, + approved_channel_user_id: Option<&str>, + ) -> Result<(), DbError> { let mut pairings = self.pairings.lock().unwrap(); - if let Some(p) = pairings.iter_mut().find(|p| p.code == code) { + if let Some(p) = pairings.iter_mut().find(|p| p.id == id) { p.status = status.to_owned(); + if let Some(user_id) = approved_channel_user_id { + p.approved_channel_user_id = Some(user_id.to_owned()); + } Ok(()) } else { - Err(DbError::NotFound(code.into())) + Err(DbError::NotFound(id.into())) + } + } + async fn expire_pending_pairings_for_user( + &self, + _owner_user_id: &str, + connection_id: &str, + external_user_id: &str, + ) -> Result { + let mut pairings = self.pairings.lock().unwrap(); + let mut count = 0u64; + for p in pairings.iter_mut() { + if p.status == "pending" && p.connection_id == connection_id && p.platform_user_id == external_user_id { + p.status = "expired".into(); + count += 1; + } } + Ok(count) } async fn cleanup_expired_pairings(&self, _owner_user_id: &str, _now: TimestampMs) -> Result { Ok(0) @@ -768,14 +908,31 @@ mod tests { async fn delete_keys(&self, _user_id: &str, _keys: &[&str]) -> Result<(), DbError> { Ok(()) } + // Channel settings only use account-scope keys; the device scope is + // never exercised from here. + async fn get_all_device(&self) -> Result, DbError> { + Ok(vec![]) + } + async fn get_device_by_keys(&self, _keys: &[&str]) -> Result, DbError> { + Ok(vec![]) + } + async fn upsert_device_batch(&self, _entries: &[(&str, &str)]) -> Result<(), DbError> { + Ok(()) + } + async fn delete_device_keys(&self, _keys: &[&str]) -> Result<(), DbError> { + Ok(()) + } } // ── Test helpers ─────────────────────────────────────────────────── + /// Fixed key so pairing hashes are reproducible across the tests. + const TEST_CODE_HASH_KEY: [u8; 32] = [0x42u8; 32]; + fn setup() -> (ActionExecutor, Arc) { let repo = Arc::new(MockRepo::new()); let broadcaster = Arc::new(MockBroadcaster); - let pairing = Arc::new(PairingService::new(repo.clone(), broadcaster)); + let pairing = Arc::new(PairingService::new(repo.clone(), broadcaster, TEST_CODE_HASH_KEY)); let session_mgr = Arc::new(SessionManager::new(repo.clone())); let pref_repo: Arc = Arc::new(MockPrefRepo); let settings = Arc::new(ChannelSettingsService::new(pref_repo)); @@ -786,7 +943,7 @@ mod tests { fn setup_without_owner() -> (ActionExecutor, Arc) { let repo = Arc::new(MockRepo::new()); let broadcaster = Arc::new(MockBroadcaster); - let pairing = Arc::new(PairingService::new(repo.clone(), broadcaster)); + let pairing = Arc::new(PairingService::new(repo.clone(), broadcaster, TEST_CODE_HASH_KEY)); let session_mgr = Arc::new(SessionManager::new(repo.clone())); let pref_repo: Arc = Arc::new(MockPrefRepo); let settings = Arc::new(ChannelSettingsService::new(pref_repo)); @@ -797,6 +954,7 @@ mod tests { fn make_text_message(user_id: &str, chat_id: &str, text: &str, platform: PluginType) -> UnifiedIncomingMessage { UnifiedIncomingMessage { owner_user_id: None, + connection_id: None, id: "msg_1".into(), platform, chat_id: chat_id.into(), @@ -828,6 +986,7 @@ mod tests { ) -> UnifiedIncomingMessage { UnifiedIncomingMessage { owner_user_id: None, + connection_id: None, id: "msg_1".into(), platform, chat_id: chat_id.into(), diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 4d632b411..238801620 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -6,8 +6,8 @@ use aionui_api_types::{ }; use aionui_common::ProviderWithModel; use aionui_db::{ - IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IClientPreferenceRepository, - resolve_agent_binding_from_rows, + IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IChannelRepository, + IClientPreferenceRepository, resolve_agent_binding_from_rows, }; use tracing::debug; @@ -18,11 +18,17 @@ const DEFAULT_AGENT_TYPE: &str = "aionrs"; /// Per-plugin agent/model configuration read from `client_preferences`. /// -/// Keys follow the pattern established by the old Electron frontend: -/// - `assistant.{platform}.agent` → JSON `{"backend":"claude","name":"Claude"}` -/// - `assistant.{platform}.defaultModel` → JSON `{"id":"provider_id","use_model":"model_name"}` +/// Keys (channel refactor A4) are connection-scoped with a legacy fallback: +/// - `assistant.{connection_id}.agent` → JSON `{"backend":"claude","name":"Claude"}` +/// - `assistant.{connection_id}.defaultModel` → JSON `{"id":"provider_id","use_model":"model_name"}` +/// +/// Reads fall back to the legacy platform-keyed entries +/// (`assistant.{platform}.*`) written before connections existed; writes land +/// on the connection key whenever the platform has a connection. Phase 1 keeps +/// one connection per (owner, platform), so old and new keys map one-to-one. pub struct ChannelSettingsService { pref_repo: Arc, + channel_repo: Option>, agent_metadata_repo: Option>, assistant_definition_repo: Option>, assistant_overlay_repo: Option>, @@ -62,6 +68,7 @@ impl ChannelSettingsService { pub fn new(pref_repo: Arc) -> Self { Self { pref_repo, + channel_repo: None, agent_metadata_repo: None, assistant_definition_repo: None, assistant_overlay_repo: None, @@ -69,6 +76,64 @@ impl ChannelSettingsService { } } + /// Enables connection-scoped preference keys. Without this the service + /// keeps addressing settings by platform key only (legacy behavior). + pub fn with_channel_repo(mut self, channel_repo: Arc) -> Self { + self.channel_repo = Some(channel_repo); + self + } + + /// Resolves the owner's connection id for a platform, if any. + async fn connection_id_for(&self, user_id: &str, platform: PluginType) -> Result, ChannelError> { + let Some(repo) = &self.channel_repo else { + return Ok(None); + }; + Ok(repo + .get_connection_by_plugin_key(user_id, &platform.to_string()) + .await? + .map(|row| row.id)) + } + + /// Read-order keys for one setting: connection key first (when a + /// connection exists), then the legacy platform key. + async fn read_keys( + &self, + user_id: &str, + platform: PluginType, + build: fn(&str) -> String, + ) -> Result, ChannelError> { + let mut keys = Vec::with_capacity(2); + if let Some(connection_id) = self.connection_id_for(user_id, platform).await? { + keys.push(build(&connection_id)); + } + keys.push(build(&platform.to_string())); + Ok(keys) + } + + /// Write key for one setting: the connection key when a connection + /// exists, else the legacy platform key (a platform can be configured + /// before its plugin is first enabled). + async fn write_key( + &self, + user_id: &str, + platform: PluginType, + build: fn(&str) -> String, + ) -> Result { + Ok(match self.connection_id_for(user_id, platform).await? { + Some(connection_id) => build(&connection_id), + None => build(&platform.to_string()), + }) + } + + /// Picks the highest-priority preference row according to `keys` order. + fn pick_preferred( + keys: &[String], + prefs: Vec, + ) -> Option { + keys.iter() + .find_map(|key| prefs.iter().find(|p| &p.key == key).cloned()) + } + pub fn with_generated_assistant_materializer( mut self, materializer: Arc, @@ -104,10 +169,11 @@ impl ChannelSettingsService { user_id: &str, platform: PluginType, ) -> Result { - let key = agent_key(platform); - let prefs = self.pref_repo.get_by_keys(user_id, &[&key]).await?; + let keys = self.read_keys(user_id, platform, agent_key).await?; + let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect(); + let prefs = self.pref_repo.get_by_keys(user_id, &key_refs).await?; - let Some(pref) = prefs.into_iter().next() else { + let Some(pref) = Self::pick_preferred(&keys, prefs) else { return Ok(default_agent_config()); }; @@ -167,10 +233,11 @@ impl ChannelSettingsService { user_id: &str, platform: PluginType, ) -> Result, ChannelError> { - let key = model_key(platform); - let prefs = self.pref_repo.get_by_keys(user_id, &[&key]).await?; + let keys = self.read_keys(user_id, platform, model_key).await?; + let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect(); + let prefs = self.pref_repo.get_by_keys(user_id, &key_refs).await?; - let Some(pref) = prefs.into_iter().next() else { + let Some(pref) = Self::pick_preferred(&keys, prefs) else { return Ok(None); }; @@ -197,24 +264,24 @@ impl ChannelSettingsService { user_id: &str, platform: PluginType, ) -> Result { - let key_agent = agent_key(platform); - let key_model = model_key(platform); - let prefs = self.pref_repo.get_by_keys(user_id, &[&key_agent, &key_model]).await?; + let agent_keys = self.read_keys(user_id, platform, agent_key).await?; + let model_keys = self.read_keys(user_id, platform, model_key).await?; + let all_keys: Vec<&str> = agent_keys.iter().chain(model_keys.iter()).map(String::as_str).collect(); + let prefs = self.pref_repo.get_by_keys(user_id, &all_keys).await?; let mut assistant = None; let mut default_model = None; - for pref in prefs { - if pref.key == key_agent { - if let Some(parsed) = parse_channel_assistant_setting(&pref.value) { - assistant = Some( - self.normalize_channel_assistant_setting_for_response(user_id, parsed) - .await?, - ); - } - } else if pref.key == key_model { - default_model = parse_channel_model_setting(&pref.value); - } + if let Some(pref) = Self::pick_preferred(&agent_keys, prefs.clone()) + && let Some(parsed) = parse_channel_assistant_setting(&pref.value) + { + assistant = Some( + self.normalize_channel_assistant_setting_for_response(user_id, parsed) + .await?, + ); + } + if let Some(pref) = Self::pick_preferred(&model_keys, prefs) { + default_model = parse_channel_model_setting(&pref.value); } if assistant.is_none() { @@ -233,10 +300,11 @@ impl ChannelSettingsService { user_id: &str, platform: PluginType, ) -> Result, ChannelError> { - let key = agent_key(platform); - let prefs = self.pref_repo.get_by_keys(user_id, &[&key]).await?; + let keys = self.read_keys(user_id, platform, agent_key).await?; + let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect(); + let prefs = self.pref_repo.get_by_keys(user_id, &key_refs).await?; - let Some(pref) = prefs.into_iter().next() else { + let Some(pref) = Self::pick_preferred(&keys, prefs) else { return self.resolve_default_channel_assistant_setting(user_id).await; }; @@ -260,7 +328,7 @@ impl ChannelSettingsService { ) -> Result<(), ChannelError> { let normalized = normalize_channel_assistant_setting_for_write(assistant); let payload = serde_json::to_string(&normalized).map_err(ChannelError::Json)?; - let key = agent_key(platform); + let key = self.write_key(user_id, platform, agent_key).await?; self.pref_repo .upsert_batch(user_id, &[(&key, payload.as_str())]) .await?; @@ -274,7 +342,7 @@ impl ChannelSettingsService { model: &ChannelDefaultModelSetting, ) -> Result<(), ChannelError> { let payload = serde_json::to_string(model).map_err(ChannelError::Json)?; - let key = model_key(platform); + let key = self.write_key(user_id, platform, model_key).await?; self.pref_repo .upsert_batch(user_id, &[(&key, payload.as_str())]) .await?; @@ -463,12 +531,14 @@ impl ChannelSettingsService { } } -fn agent_key(platform: PluginType) -> String { - format!("assistant.{platform}.agent") +/// `scope` is a connection id (current) or a platform key (legacy fallback). +fn agent_key(scope: &str) -> String { + format!("assistant.{scope}.agent") } -fn model_key(platform: PluginType) -> String { - format!("assistant.{platform}.defaultModel") +/// `scope` is a connection id (current) or a platform key (legacy fallback). +fn model_key(scope: &str) -> String { + format!("assistant.{scope}.defaultModel") } fn default_agent_config() -> ResolvedAgentConfig { @@ -591,7 +661,8 @@ mod tests { Ok(data .iter() .map(|(k, v)| ClientPreference { - user_id: TEST_USER_ID.to_owned(), + scope: "account".to_owned(), + user_id: Some(TEST_USER_ID.to_owned()), key: k.clone(), value: v.clone(), updated_at: 0, @@ -605,7 +676,8 @@ mod tests { .iter() .filter(|(k, _)| keys.contains(&k.as_str())) .map(|(k, v)| ClientPreference { - user_id: TEST_USER_ID.to_owned(), + scope: "account".to_owned(), + user_id: Some(TEST_USER_ID.to_owned()), key: k.clone(), value: v.clone(), updated_at: 0, @@ -630,6 +702,24 @@ mod tests { data.retain(|(k, _)| !keys.contains(&k.as_str())); Ok(()) } + + // Channel settings keys (`assistant.{platform}.*`) are per-account by + // design; touching the device scope from here would be a bug. + async fn get_all_device(&self) -> Result, DbError> { + unreachable!("channel settings must not read device-scope preferences") + } + + async fn get_device_by_keys(&self, _keys: &[&str]) -> Result, DbError> { + unreachable!("channel settings must not read device-scope preferences") + } + + async fn upsert_device_batch(&self, _entries: &[(&str, &str)]) -> Result<(), DbError> { + unreachable!("channel settings must not write device-scope preferences") + } + + async fn delete_device_keys(&self, _keys: &[&str]) -> Result<(), DbError> { + unreachable!("channel settings must not write device-scope preferences") + } } struct MockAssistantDefinitionRepo { @@ -1298,4 +1388,137 @@ mod tests { assert!(p.model.is_empty()); assert!(p.use_model.is_none()); } + + // ── Connection-scoped preference keys (A4) ──────────────────────── + + mod connection_scoped_keys { + use super::*; + use aionui_db::models::ChannelConnectionRow; + use aionui_db::{IUserRepository, SqliteChannelRepository, SqliteUserRepository, init_database_memory}; + + const CONN_ID: &str = "conn_test_telegram"; + + /// Real in-memory channel repo with one telegram connection owned by + /// the returned user id. + async fn channel_repo_with_connection() -> (Arc, String) { + let db = init_database_memory().await.unwrap(); + let owner = SqliteUserRepository::new(db.pool().clone()) + .create_user("settings-owner", "hash") + .await + .unwrap() + .id; + let repo = SqliteChannelRepository::new(db.pool().clone()); + let now = aionui_common::now_ms(); + repo.upsert_connection( + &owner, + &ChannelConnectionRow { + id: CONN_ID.into(), + owner_user_id: owner.clone(), + plugin_key: "telegram".into(), + name: "TG".into(), + enabled: true, + config: String::new(), + status: None, + last_connected: None, + created_at: now, + updated_at: now, + }, + ) + .await + .unwrap(); + std::mem::forget(db); + (Arc::new(repo), owner) + } + + #[tokio::test] + async fn writes_land_on_the_connection_key() { + let (channel_repo, owner) = channel_repo_with_connection().await; + let prefs = Arc::new(MockPrefRepo::new()); + let svc = ChannelSettingsService::new(prefs.clone()).with_channel_repo(channel_repo); + + svc.set_assistant_setting( + &owner, + PluginType::Telegram, + &ChannelAssistantSettingRequest { + assistant_id: "bare-claude".into(), + name: None, + }, + ) + .await + .unwrap(); + + let stored = prefs + .get_by_keys(&owner, &["assistant.conn_test_telegram.agent"]) + .await + .unwrap(); + assert_eq!(stored.len(), 1, "write must land on the connection key"); + // The legacy platform key is NOT written. + assert!( + prefs + .get_by_keys(&owner, &["assistant.telegram.agent"]) + .await + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn reads_prefer_the_connection_key_over_the_legacy_platform_key() { + let (channel_repo, owner) = channel_repo_with_connection().await; + let prefs = Arc::new(MockPrefRepo::with_data(vec![ + ( + "assistant.conn_test_telegram.agent", + r#"{"agent_type":"acp","backend":"claude"}"#, + ), + ("assistant.telegram.agent", r#"{"agent_type":"aionrs"}"#), + ])); + let svc = ChannelSettingsService::new(prefs).with_channel_repo(channel_repo); + + let config = svc.get_agent_config(&owner, PluginType::Telegram).await.unwrap(); + assert_eq!(config.agent_type, "acp"); + assert_eq!(config.backend.as_deref(), Some("claude")); + } + + #[tokio::test] + async fn reads_fall_back_to_the_legacy_platform_key() { + let (channel_repo, owner) = channel_repo_with_connection().await; + let prefs = Arc::new(MockPrefRepo::with_data(vec![( + "assistant.telegram.agent", + r#"{"agent_type":"aionrs"}"#, + )])); + let svc = ChannelSettingsService::new(prefs).with_channel_repo(channel_repo); + + let config = svc.get_agent_config(&owner, PluginType::Telegram).await.unwrap(); + assert_eq!(config.agent_type, "aionrs"); + } + + #[tokio::test] + async fn platform_without_a_connection_keeps_the_platform_key() { + let (channel_repo, owner) = channel_repo_with_connection().await; + let prefs = Arc::new(MockPrefRepo::new()); + let svc = ChannelSettingsService::new(prefs.clone()).with_channel_repo(channel_repo); + + // Lark has no connection row: the write stays on the platform key + // so a platform can be configured before its plugin is enabled. + svc.set_model_setting( + &owner, + PluginType::Lark, + &ChannelDefaultModelSetting { + id: "prov".into(), + use_model: "m1".into(), + }, + ) + .await + .unwrap(); + + assert_eq!( + prefs + .get_by_keys(&owner, &["assistant.lark.defaultModel"]) + .await + .unwrap() + .len(), + 1 + ); + } + } } diff --git a/crates/aionui-channel/src/manager.rs b/crates/aionui-channel/src/manager.rs index 3cc32de50..55667ebad 100644 --- a/crates/aionui-channel/src/manager.rs +++ b/crates/aionui-channel/src/manager.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use aionui_api_types::{PluginStatusChangedPayload, PluginStatusResponse, WebSocketMessage}; use aionui_common::{decrypt_string, encrypt_string, now_ms}; -use aionui_db::models::ChannelPluginRow; -use aionui_db::{IChannelRepository, UpdatePluginStatusParams}; +use aionui_db::models::ChannelConnectionRow; +use aionui_db::{IChannelRepository, UpdateConnectionStatusParams}; use aionui_realtime::EventBroadcaster; use dashmap::DashMap; use tokio::sync::mpsc; @@ -28,7 +28,7 @@ pub struct ChannelManager { repo: Arc, broadcaster: Arc, encryption_key: [u8; 32], - /// Active plugin instances keyed by owner user ID + plugin ID. + /// Active plugin instances keyed by owner user ID + connection ID. plugins: DashMap>, /// Sender for incoming messages from all plugins. /// The `ActionExecutor` holds the receiving end. @@ -40,14 +40,14 @@ pub struct ChannelManager { #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct ChannelRuntimeKey { owner_user_id: String, - plugin_id: String, + connection_id: String, } impl ChannelRuntimeKey { - fn new(owner_user_id: &str, plugin_id: &str) -> Self { + fn new(owner_user_id: &str, connection_id: &str) -> Self { Self { owner_user_id: owner_user_id.to_owned(), - plugin_id: plugin_id.to_owned(), + connection_id: connection_id.to_owned(), } } } @@ -93,7 +93,7 @@ impl ChannelManager { /// /// Merges DB state with live runtime status for active plugins. pub async fn get_plugin_status(&self, owner_user_id: &str) -> Result, ChannelError> { - let rows = self.repo.get_all_plugins(owner_user_id).await?; + let rows = self.repo.get_all_connections(owner_user_id).await?; let statuses: Vec = rows .into_iter() .map(|row| { @@ -115,18 +115,27 @@ impl ChannelManager { /// /// # Arguments /// - /// - `plugin_id`: Platform identifier (e.g., "telegram") + /// - `plugin_key`: Platform identifier (e.g., "telegram") /// - `config_value`: Raw JSON config containing credentials and options /// - `factory`: Function to create the platform-specific plugin instance + /// + /// Phase 1 keeps one connection per (owner, plugin_key): the existing + /// connection row is reused when present, otherwise a fresh connection id + /// is generated. pub async fn enable_plugin( &self, owner_user_id: &str, - plugin_id: &str, + plugin_key: &str, config_value: &serde_json::Value, factory: &PluginFactory, ) -> Result<(), ChannelError> { - let plugin_type = - PluginType::from_str_opt(plugin_id).ok_or_else(|| ChannelError::InvalidPluginType(plugin_id.to_owned()))?; + let plugin_type = PluginType::from_str_opt(plugin_key) + .ok_or_else(|| ChannelError::InvalidPluginType(plugin_key.to_owned()))?; + + let existing = self + .repo + .get_connection_by_plugin_key(owner_user_id, plugin_key) + .await?; // Resolve the effective config. The Settings re-enable toggle sends an // empty config and expects the previously stored credentials to be @@ -134,10 +143,15 @@ impl ChannelManager { // are supplied. let config: PluginConfig = match Self::config_with_credentials(config_value)? { Some(config) => config, - None => self.load_stored_config(owner_user_id, plugin_id).await?, + None => Self::decrypt_stored_config(existing.as_ref(), plugin_key, &self.encryption_key)?, }; - self.stop_plugin(owner_user_id, plugin_id).await; + let connection_id = existing + .as_ref() + .map(|row| row.id.clone()) + .unwrap_or_else(|| aionui_common::generate_prefixed_id("conn")); + + self.stop_plugin(owner_user_id, &connection_id).await; // Encrypt config for storage let config_json = serde_json::to_string(&config)?; @@ -146,53 +160,61 @@ impl ChannelManager { // Persist to DB let now = now_ms(); - let row = ChannelPluginRow { - id: plugin_id.to_owned(), + let row = ChannelConnectionRow { + id: connection_id.clone(), owner_user_id: owner_user_id.to_owned(), - r#type: plugin_type.to_string(), + plugin_key: plugin_type.to_string(), name: self.default_plugin_name(plugin_type), enabled: true, config: encrypted_config, status: Some(PluginStatus::Created.to_string()), - last_connected: None, - created_at: now, + last_connected: existing.as_ref().and_then(|row| row.last_connected), + created_at: existing.as_ref().map(|row| row.created_at).unwrap_or(now), updated_at: now, }; - self.repo.upsert_plugin(owner_user_id, &row).await?; + self.repo.upsert_connection(owner_user_id, &row).await?; // Create and start plugin instance let mut plugin = factory(plugin_type) .ok_or_else(|| ChannelError::InvalidPluginType(format!("No implementation for {plugin_type}")))?; - let callbacks = self.callbacks_for_owner(owner_user_id); + let callbacks = self.callbacks_for_connection(owner_user_id, &connection_id); if let Err(e) = plugin.initialize(config, callbacks).await { - self.update_plugin_error(owner_user_id, plugin_id, &e.to_string()).await; - self.broadcast_status_change(owner_user_id, plugin_id).await; + self.update_plugin_error(owner_user_id, &connection_id, &e.to_string()) + .await; + self.broadcast_status_change(owner_user_id, &connection_id).await; return Err(e); } if let Err(e) = plugin.start().await { - self.update_plugin_error(owner_user_id, plugin_id, &e.to_string()).await; - self.broadcast_status_change(owner_user_id, plugin_id).await; + self.update_plugin_error(owner_user_id, &connection_id, &e.to_string()) + .await; + self.broadcast_status_change(owner_user_id, &connection_id).await; return Err(e); } // Update DB with running status - let params = UpdatePluginStatusParams { + let params = UpdateConnectionStatusParams { status: Some(PluginStatus::Running.to_string()), last_connected: Some(now_ms()), enabled: None, }; self.repo - .update_plugin_status(owner_user_id, plugin_id, ¶ms) + .update_connection_status(owner_user_id, &connection_id, ¶ms) .await?; // Store active instance - self.plugins.insert(Self::runtime_key(owner_user_id, plugin_id), plugin); - - info!(owner_user_id = %owner_user_id, plugin_id = %plugin_id, "plugin enabled and started"); - self.broadcast_status_change(owner_user_id, plugin_id).await; + self.plugins + .insert(Self::runtime_key(owner_user_id, &connection_id), plugin); + + info!( + owner_user_id = %owner_user_id, + plugin_key = %plugin_key, + connection_id = %connection_id, + "plugin enabled and started" + ); + self.broadcast_status_change(owner_user_id, &connection_id).await; Ok(()) } @@ -204,22 +226,29 @@ impl ChannelManager { pub async fn enable_extension_plugin( &self, owner_user_id: &str, - plugin_id: &str, + plugin_key: &str, plugin_name: &str, config: &PluginConfig, ) -> Result<(), ChannelError> { - self.stop_plugin(owner_user_id, plugin_id).await; - let config_json = serde_json::to_string(config)?; let encrypted_config = encrypt_string(&config_json, &self.encryption_key) .map_err(|e| ChannelError::EncryptionFailed(e.to_string()))?; let now = now_ms(); - let existing = self.repo.get_plugin(owner_user_id, plugin_id).await?; - let row = ChannelPluginRow { - id: plugin_id.to_owned(), + let existing = self + .repo + .get_connection_by_plugin_key(owner_user_id, plugin_key) + .await?; + let connection_id = existing + .as_ref() + .map(|row| row.id.clone()) + .unwrap_or_else(|| aionui_common::generate_prefixed_id("conn")); + self.stop_plugin(owner_user_id, &connection_id).await; + + let row = ChannelConnectionRow { + id: connection_id.clone(), owner_user_id: owner_user_id.to_owned(), - r#type: plugin_id.to_owned(), + plugin_key: plugin_key.to_owned(), name: plugin_name.to_owned(), enabled: true, config: encrypted_config, @@ -228,10 +257,15 @@ impl ChannelManager { created_at: existing.as_ref().map(|row| row.created_at).unwrap_or(now), updated_at: now, }; - self.repo.upsert_plugin(owner_user_id, &row).await?; - - info!(owner_user_id = %owner_user_id, plugin_id = %plugin_id, "extension plugin enabled (metadata-only mode)"); - self.broadcast_status_change(owner_user_id, plugin_id).await; + self.repo.upsert_connection(owner_user_id, &row).await?; + + info!( + owner_user_id = %owner_user_id, + plugin_key = %plugin_key, + connection_id = %connection_id, + "extension plugin enabled (metadata-only mode)" + ); + self.broadcast_status_change(owner_user_id, &connection_id).await; Ok(()) } @@ -239,22 +273,33 @@ impl ChannelManager { /// the active instance. /// /// Idempotent — disabling an already-disabled plugin is a no-op. - pub async fn disable_plugin(&self, owner_user_id: &str, plugin_id: &str) -> Result<(), ChannelError> { + pub async fn disable_plugin(&self, owner_user_id: &str, plugin_key: &str) -> Result<(), ChannelError> { + let connection = self + .repo + .get_connection_by_plugin_key(owner_user_id, plugin_key) + .await? + .ok_or_else(|| ChannelError::PluginNotFound(plugin_key.to_owned()))?; + // Stop running instance if any - self.stop_plugin(owner_user_id, plugin_id).await; + self.stop_plugin(owner_user_id, &connection.id).await; // Update DB - let params = UpdatePluginStatusParams { + let params = UpdateConnectionStatusParams { status: Some(PluginStatus::Stopped.to_string()), last_connected: None, enabled: Some(false), }; self.repo - .update_plugin_status(owner_user_id, plugin_id, ¶ms) + .update_connection_status(owner_user_id, &connection.id, ¶ms) .await?; - info!(owner_user_id = %owner_user_id, plugin_id = %plugin_id, "plugin disabled"); - self.broadcast_status_change(owner_user_id, plugin_id).await; + info!( + owner_user_id = %owner_user_id, + plugin_key = %plugin_key, + connection_id = %connection.id, + "plugin disabled" + ); + self.broadcast_status_change(owner_user_id, &connection.id).await; Ok(()) } @@ -298,8 +343,8 @@ impl ChannelManager { /// starts them. Errors on individual plugins are logged but don't /// prevent other plugins from starting. pub async fn restore_plugins(&self, owner_user_id: &str, factory: &PluginFactory) -> Result<(), ChannelError> { - let rows = self.repo.get_all_plugins(owner_user_id).await?; - let enabled: Vec = rows.into_iter().filter(|r| r.enabled).collect(); + let rows = self.repo.get_all_connections(owner_user_id).await?; + let enabled: Vec = rows.into_iter().filter(|r| r.enabled).collect(); if enabled.is_empty() { debug!("no enabled plugins to restore"); @@ -309,10 +354,10 @@ impl ChannelManager { info!(owner_user_id = %owner_user_id, count = enabled.len(), "restoring enabled plugins"); for row in enabled { - if PluginType::from_str_opt(&row.r#type).is_none() { + if PluginType::from_str_opt(&row.plugin_key).is_none() { info!( - plugin_id = %row.id, - plugin_type = %row.r#type, + connection_id = %row.id, + plugin_key = %row.plugin_key, "skipping extension plugin runtime restore; metadata-only mode" ); self.broadcast_status_change(owner_user_id, &row.id).await; @@ -321,7 +366,8 @@ impl ChannelManager { if let Err(e) = self.restore_single_plugin(owner_user_id, &row, factory).await { warn!( owner_user_id = %owner_user_id, - plugin_id = %row.id, + connection_id = %row.id, + plugin_key = %row.plugin_key, error = %e, "failed to restore plugin, marking as error" ); @@ -372,48 +418,71 @@ impl ChannelManager { self.plugins.len() } - /// Checks whether a specific plugin is currently running. - pub fn is_plugin_running(&self, owner_user_id: &str, plugin_id: &str) -> bool { - self.plugins - .get(&Self::runtime_key(owner_user_id, plugin_id)) - .map(|p| p.status() == PluginStatus::Running) + /// Checks whether the owner's plugin for a platform is currently running. + /// + /// Callers still address channels by platform (`plugin_key`) until + /// sessions carry connection ids (segment A2/A3); the runtime map is + /// keyed by connection id, so resolve by scanning the owner's live + /// instances (phase 1: at most one per platform). + pub fn is_plugin_running(&self, owner_user_id: &str, plugin_key: &str) -> bool { + self.live_runtime_key(owner_user_id, plugin_key) + .and_then(|key| self.plugins.get(&key).map(|p| p.status() == PluginStatus::Running)) .unwrap_or(false) } - /// Sends a message through a specific plugin. + /// Sends a message through the owner's plugin for a platform. /// /// Used by the `ChannelMessageService` to route outgoing messages /// to the correct platform plugin. pub async fn send_message( &self, owner_user_id: &str, - plugin_id: &str, + plugin_key: &str, chat_id: &str, message: crate::types::UnifiedOutgoingMessage, ) -> Result { + let key = self + .live_runtime_key(owner_user_id, plugin_key) + .ok_or_else(|| ChannelError::PluginNotFound(plugin_key.to_owned()))?; let plugin = self .plugins - .get(&Self::runtime_key(owner_user_id, plugin_id)) - .ok_or_else(|| ChannelError::PluginNotFound(plugin_id.to_owned()))?; + .get(&key) + .ok_or_else(|| ChannelError::PluginNotFound(plugin_key.to_owned()))?; plugin.send_message(chat_id, message).await } - /// Edits an existing message through a specific plugin. + /// Edits an existing message through the owner's plugin for a platform. pub async fn edit_message( &self, owner_user_id: &str, - plugin_id: &str, + plugin_key: &str, chat_id: &str, message_id: &str, message: crate::types::UnifiedOutgoingMessage, ) -> Result<(), ChannelError> { + let key = self + .live_runtime_key(owner_user_id, plugin_key) + .ok_or_else(|| ChannelError::PluginNotFound(plugin_key.to_owned()))?; let plugin = self .plugins - .get(&Self::runtime_key(owner_user_id, plugin_id)) - .ok_or_else(|| ChannelError::PluginNotFound(plugin_id.to_owned()))?; + .get(&key) + .ok_or_else(|| ChannelError::PluginNotFound(plugin_key.to_owned()))?; plugin.edit_message(chat_id, message_id, message).await } + /// Resolves the live runtime key for the owner's plugin of a platform. + /// + /// Only built-in platform plugins ever enter the runtime map, and phase 1 + /// keeps at most one connection per (owner, platform), so a scan over the + /// owner's live instances is unambiguous. + fn live_runtime_key(&self, owner_user_id: &str, plugin_key: &str) -> Option { + let plugin_type = PluginType::from_str_opt(plugin_key)?; + self.plugins + .iter() + .find(|entry| entry.key().owner_user_id == owner_user_id && entry.value().plugin_type() == plugin_type) + .map(|entry| entry.key().clone()) + } + // ── Private helpers ────────────────────────────────────────────── /// Parses a freshly supplied plugin config, returning it only when it @@ -441,41 +510,43 @@ impl ChannelManager { } } - /// Loads and decrypts the persisted config for a plugin. + /// Decrypts the persisted config of an already-resolved connection row. /// /// Used when an enable request omits credentials and the stored /// configuration should be reused (Settings re-enable toggle). Returns /// `InvalidConfig` when there is no stored config to fall back to. - async fn load_stored_config(&self, owner_user_id: &str, plugin_id: &str) -> Result { - let row = self - .repo - .get_plugin(owner_user_id, plugin_id) - .await? - .filter(|row| !row.config.is_empty()) - .ok_or_else(|| { - ChannelError::InvalidConfig(format!( - "No credentials provided and no stored configuration for plugin '{plugin_id}'" - )) - })?; - - let config_json = decrypt_string(&row.config, &self.encryption_key) - .map_err(|e| ChannelError::DecryptionFailed(e.to_string()))?; + fn decrypt_stored_config( + existing: Option<&ChannelConnectionRow>, + plugin_key: &str, + encryption_key: &[u8; 32], + ) -> Result { + let row = existing.filter(|row| !row.config.is_empty()).ok_or_else(|| { + ChannelError::InvalidConfig(format!( + "No credentials provided and no stored configuration for plugin '{plugin_key}'" + )) + })?; + + let config_json = + decrypt_string(&row.config, encryption_key).map_err(|e| ChannelError::DecryptionFailed(e.to_string()))?; let config: PluginConfig = serde_json::from_str(&config_json)?; Ok(config) } - /// Stops and removes an active plugin instance. - fn runtime_key(owner_user_id: &str, plugin_id: &str) -> ChannelRuntimeKey { - ChannelRuntimeKey::new(owner_user_id, plugin_id) + fn runtime_key(owner_user_id: &str, connection_id: &str) -> ChannelRuntimeKey { + ChannelRuntimeKey::new(owner_user_id, connection_id) } - fn callbacks_for_owner(&self, owner_user_id: &str) -> PluginCallbacks { + /// Builds plugin callbacks that stamp the owning user and connection onto + /// every incoming message before it enters the shared pipeline. + fn callbacks_for_connection(&self, owner_user_id: &str, connection_id: &str) -> PluginCallbacks { let (plugin_msg_tx, mut plugin_msg_rx) = mpsc::channel::(64); let message_tx = self.message_tx.clone(); let owner_user_id = owner_user_id.to_owned(); + let connection_id = connection_id.to_owned(); tokio::spawn(async move { while let Some(mut msg) = plugin_msg_rx.recv().await { msg.owner_user_id = Some(owner_user_id.clone()); + msg.connection_id = Some(connection_id.clone()); if message_tx.send(msg).await.is_err() { break; } @@ -488,8 +559,8 @@ impl ChannelManager { } } - async fn stop_plugin(&self, owner_user_id: &str, plugin_id: &str) { - let key = Self::runtime_key(owner_user_id, plugin_id); + async fn stop_plugin(&self, owner_user_id: &str, connection_id: &str) { + let key = Self::runtime_key(owner_user_id, connection_id); self.stop_plugin_by_key(&key).await; } @@ -512,11 +583,11 @@ impl ChannelManager { async fn restore_single_plugin( &self, owner_user_id: &str, - row: &ChannelPluginRow, + row: &ChannelConnectionRow, factory: &PluginFactory, ) -> Result<(), ChannelError> { - let plugin_type = - PluginType::from_str_opt(&row.r#type).ok_or_else(|| ChannelError::InvalidPluginType(row.r#type.clone()))?; + let plugin_type = PluginType::from_str_opt(&row.plugin_key) + .ok_or_else(|| ChannelError::InvalidPluginType(row.plugin_key.clone()))?; // Decrypt config let config_json = decrypt_string(&row.config, &self.encryption_key) @@ -526,36 +597,47 @@ impl ChannelManager { let mut plugin = factory(plugin_type) .ok_or_else(|| ChannelError::InvalidPluginType(format!("No implementation for {plugin_type}")))?; - let callbacks = self.callbacks_for_owner(owner_user_id); + let callbacks = self.callbacks_for_connection(owner_user_id, &row.id); plugin.initialize(config, callbacks).await?; plugin.start().await?; // Update DB with running status - let params = UpdatePluginStatusParams { + let params = UpdateConnectionStatusParams { status: Some(PluginStatus::Running.to_string()), last_connected: Some(now_ms()), enabled: None, }; - self.repo.update_plugin_status(owner_user_id, &row.id, ¶ms).await?; + self.repo + .update_connection_status(owner_user_id, &row.id, ¶ms) + .await?; self.plugins.insert(Self::runtime_key(owner_user_id, &row.id), plugin); - info!(owner_user_id = %owner_user_id, plugin_id = %row.id, "plugin restored"); + info!( + owner_user_id = %owner_user_id, + connection_id = %row.id, + plugin_key = %row.plugin_key, + "plugin restored" + ); self.broadcast_status_change(owner_user_id, &row.id).await; Ok(()) } - /// Updates a plugin to error status in the DB. - async fn update_plugin_error(&self, owner_user_id: &str, plugin_id: &str, error_msg: &str) { - let params = UpdatePluginStatusParams { + /// Updates a connection to error status in the DB. + async fn update_plugin_error(&self, owner_user_id: &str, connection_id: &str, error_msg: &str) { + let params = UpdateConnectionStatusParams { status: Some(PluginStatus::Error.to_string()), last_connected: None, enabled: None, }; - if let Err(e) = self.repo.update_plugin_status(owner_user_id, plugin_id, ¶ms).await { + if let Err(e) = self + .repo + .update_connection_status(owner_user_id, connection_id, ¶ms) + .await + { error!( owner_user_id = %owner_user_id, - plugin_id = %plugin_id, + connection_id = %connection_id, db_error = %e, original_error = %error_msg, "failed to update plugin error status in DB" @@ -564,19 +646,23 @@ impl ChannelManager { } /// Broadcasts a `channel.plugin-status-changed` event. - async fn broadcast_status_change(&self, owner_user_id: &str, plugin_id: &str) { - let row = match self.repo.get_plugin(owner_user_id, plugin_id).await { + async fn broadcast_status_change(&self, owner_user_id: &str, connection_id: &str) { + let row = match self.repo.get_connection(owner_user_id, connection_id).await { Ok(Some(row)) => row, Ok(None) => { - warn!(owner_user_id = %owner_user_id, plugin_id = %plugin_id, "plugin not found for status broadcast"); + warn!( + owner_user_id = %owner_user_id, + connection_id = %connection_id, + "connection not found for status broadcast" + ); return; } Err(e) => { warn!( owner_user_id = %owner_user_id, - plugin_id = %plugin_id, + connection_id = %connection_id, error = %e, - "failed to read plugin for status broadcast" + "failed to read connection for status broadcast" ); return; } @@ -584,13 +670,15 @@ impl ChannelManager { let live_status = self .plugins - .get(&Self::runtime_key(owner_user_id, plugin_id)) + .get(&Self::runtime_key(owner_user_id, connection_id)) .map(|p| p.status().to_string()); let status_response = self.row_to_status_response(&row, live_status); let payload = PluginStatusChangedPayload { user_id: owner_user_id.to_owned(), - plugin_id: plugin_id.to_owned(), + // The event keeps addressing plugins by platform for the UI; + // the connection id rides inside the status payload. + plugin_id: row.plugin_key.clone(), status: status_response, }; let value = match serde_json::to_value(payload) { @@ -605,14 +693,18 @@ impl ChannelManager { } /// Converts a DB row + optional live status to a `PluginStatusResponse`. - fn row_to_status_response(&self, row: &ChannelPluginRow, live_status: Option) -> PluginStatusResponse { + /// + /// `plugin_id` stays the platform key (the UI's stable addressing); + /// `connection_id` carries the connection identity. + fn row_to_status_response(&self, row: &ChannelConnectionRow, live_status: Option) -> PluginStatusResponse { let is_running = self .plugins .contains_key(&Self::runtime_key(&row.owner_user_id, &row.id)); let has_token = !row.config.is_empty(); PluginStatusResponse { - plugin_id: row.id.clone(), - plugin_type: row.r#type.clone(), + plugin_id: row.plugin_key.clone(), + connection_id: row.id.clone(), + plugin_type: row.plugin_key.clone(), name: row.name.clone(), enabled: row.enabled, status: live_status.or_else(|| row.status.clone()), @@ -671,8 +763,10 @@ mod tests { BotInfo, OutgoingMessageType, PluginCredentials, PluginStatus, PluginType, UnifiedOutgoingMessage, }; use aionui_common::TimestampMs; - use aionui_db::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow}; - use aionui_db::{DbError, IChannelRepository, UpdatePluginStatusParams}; + use aionui_db::models::{ + ChannelConnectionRow, ChannelConversationBindingRow, ChannelPairingRequestRow, ChannelUserRow, + }; + use aionui_db::{DbError, IChannelRepository, UpdateConnectionStatusParams}; use std::collections::HashMap; use std::sync::Mutex; @@ -703,9 +797,11 @@ mod tests { // ── Mock IChannelRepository ──────────────────────────────────────── const OWNER_ID: &str = "owner-test"; + /// Connection the stub binding CRUD derives its `connection_id` from. + const STUB_CONNECTION_ID: &str = "conn-test"; struct MockRepo { - plugins: Mutex>, + plugins: Mutex>, } impl MockRepo { @@ -715,23 +811,36 @@ mod tests { } } - fn get_plugins(&self) -> Vec { + fn get_plugins(&self) -> Vec { self.plugins.lock().unwrap().clone() } } #[async_trait::async_trait] impl IChannelRepository for MockRepo { - async fn get_all_plugins(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_all_connections(&self, _owner_user_id: &str) -> Result, DbError> { Ok(self.plugins.lock().unwrap().clone()) } - async fn get_plugin(&self, _owner_user_id: &str, id: &str) -> Result, DbError> { + async fn get_connection( + &self, + _owner_user_id: &str, + id: &str, + ) -> Result, DbError> { let plugins = self.plugins.lock().unwrap(); Ok(plugins.iter().find(|p| p.id == id).cloned()) } - async fn upsert_plugin(&self, _owner_user_id: &str, row: &ChannelPluginRow) -> Result<(), DbError> { + async fn get_connection_by_plugin_key( + &self, + _owner_user_id: &str, + plugin_key: &str, + ) -> Result, DbError> { + let plugins = self.plugins.lock().unwrap(); + Ok(plugins.iter().find(|p| p.plugin_key == plugin_key).cloned()) + } + + async fn upsert_connection(&self, _owner_user_id: &str, row: &ChannelConnectionRow) -> Result<(), DbError> { let mut plugins = self.plugins.lock().unwrap(); if let Some(existing) = plugins.iter_mut().find(|p| p.id == row.id) { *existing = row.clone(); @@ -741,11 +850,11 @@ mod tests { Ok(()) } - async fn update_plugin_status( + async fn update_connection_status( &self, _owner_user_id: &str, id: &str, - params: &UpdatePluginStatusParams, + params: &UpdateConnectionStatusParams, ) -> Result<(), DbError> { let mut plugins = self.plugins.lock().unwrap(); if let Some(p) = plugins.iter_mut().find(|p| p.id == id) { @@ -765,7 +874,7 @@ mod tests { } } - async fn delete_plugin(&self, _owner_user_id: &str, id: &str) -> Result<(), DbError> { + async fn delete_connection(&self, _owner_user_id: &str, id: &str) -> Result<(), DbError> { let mut plugins = self.plugins.lock().unwrap(); let len_before = plugins.len(); plugins.retain(|p| p.id != id); @@ -777,7 +886,7 @@ mod tests { } // -- User CRUD (unused stubs) -- - async fn get_all_users(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_all_users(&self, _owner_user_id: &str) -> Result, DbError> { Ok(vec![]) } async fn get_user_by_platform( @@ -785,10 +894,10 @@ mod tests { _owner_user_id: &str, _pid: &str, _pt: &str, - ) -> Result, DbError> { + ) -> Result, DbError> { Ok(None) } - async fn create_user(&self, _owner_user_id: &str, _row: &AssistantUserRow) -> Result<(), DbError> { + async fn create_user(&self, _owner_user_id: &str, _row: &ChannelUserRow) -> Result<(), DbError> { Ok(()) } async fn update_user_last_active( @@ -799,25 +908,34 @@ mod tests { ) -> Result<(), DbError> { Ok(()) } - async fn delete_user(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { + async fn revoke_user(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { Ok(()) } // -- Session CRUD (unused stubs) -- - async fn get_all_sessions(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_all_sessions(&self, _owner_user_id: &str) -> Result, DbError> { Ok(vec![]) } - async fn get_session(&self, _owner_user_id: &str, _id: &str) -> Result, DbError> { + async fn get_session( + &self, + _owner_user_id: &str, + _id: &str, + ) -> Result, DbError> { Ok(None) } async fn get_or_create_session( &self, - _owner_user_id: &str, + owner_user_id: &str, _uid: &str, _cid: &str, - new_row: &AssistantSessionRow, - ) -> Result { - Ok(new_row.clone()) + new_row: &ChannelConversationBindingRow, + ) -> Result { + // Mirror the real INSERT: identity comes from the channel user. + Ok(ChannelConversationBindingRow { + owner_user_id: owner_user_id.to_owned(), + connection_id: STUB_CONNECTION_ID.to_owned(), + ..new_row.clone() + }) } async fn update_session_activity( &self, @@ -835,9 +953,6 @@ mod tests { ) -> Result<(), DbError> { Ok(()) } - async fn update_session_agent_type(&self, _owner_user_id: &str, _id: &str, _at: &str) -> Result<(), DbError> { - Ok(()) - } async fn delete_sessions_by_user(&self, _owner_user_id: &str, _uid: &str) -> Result<(), DbError> { Ok(()) } @@ -850,23 +965,44 @@ mod tests { Ok(()) } - // -- Pairing codes (unused stubs) -- - async fn create_pairing(&self, _owner_user_id: &str, _row: &PairingCodeRow) -> Result<(), DbError> { + // -- Pairing requests (unused stubs) -- + async fn create_pairing(&self, _owner_user_id: &str, _row: &ChannelPairingRequestRow) -> Result<(), DbError> { Ok(()) } - async fn get_pending_pairings(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_pending_pairings(&self, _owner_user_id: &str) -> Result, DbError> { Ok(vec![]) } - async fn get_pairing_by_code( + async fn get_pairing( &self, _owner_user_id: &str, - _code: &str, - ) -> Result, DbError> { + _id: &str, + ) -> Result, DbError> { Ok(None) } - async fn update_pairing_status(&self, _owner_user_id: &str, _code: &str, _status: &str) -> Result<(), DbError> { + async fn get_pending_pairing_by_code_hash( + &self, + _owner_user_id: &str, + _code_hash: &str, + ) -> Result, DbError> { + Ok(None) + } + async fn update_pairing_status( + &self, + _owner_user_id: &str, + _id: &str, + _status: &str, + _approved_channel_user_id: Option<&str>, + ) -> Result<(), DbError> { Ok(()) } + async fn expire_pending_pairings_for_user( + &self, + _owner_user_id: &str, + _connection_id: &str, + _external_user_id: &str, + ) -> Result { + Ok(0) + } async fn cleanup_expired_pairings(&self, _owner_user_id: &str, _now: TimestampMs) -> Result { Ok(0) } @@ -1064,10 +1200,10 @@ mod tests { async fn get_status_returns_db_plugins() { let (mgr, repo, _bc) = make_manager(); let now = now_ms(); - repo.plugins.lock().unwrap().push(ChannelPluginRow { + repo.plugins.lock().unwrap().push(ChannelConnectionRow { id: "telegram".into(), owner_user_id: OWNER_ID.into(), - r#type: "telegram".into(), + plugin_key: "telegram".into(), name: "Telegram Bot".into(), enabled: true, config: "encrypted".into(), @@ -1122,7 +1258,9 @@ mod tests { let plugins = repo.get_plugins(); assert_eq!(plugins.len(), 1); - assert_eq!(plugins[0].id, "telegram"); + // Connection id is generated; the platform lives in plugin_key. + assert!(plugins[0].id.starts_with("conn_"), "id: {}", plugins[0].id); + assert_eq!(plugins[0].plugin_key, "telegram"); assert!(plugins[0].enabled); // Config should be encrypted (base64), not plaintext assert_ne!(plugins[0].config, serde_json::to_string(&make_test_config()).unwrap()); @@ -1289,10 +1427,10 @@ mod tests { async fn disable_idempotent_for_not_running() { let (mgr, repo, _bc) = make_manager(); // Manually insert a disabled plugin in DB - repo.plugins.lock().unwrap().push(ChannelPluginRow { + repo.plugins.lock().unwrap().push(ChannelConnectionRow { id: "telegram".into(), owner_user_id: OWNER_ID.into(), - r#type: "telegram".into(), + plugin_key: "telegram".into(), name: "Telegram Bot".into(), enabled: false, config: "encrypted".into(), @@ -1366,10 +1504,10 @@ mod tests { let config_json = serde_json::to_string(&make_plugin_config()).unwrap(); let encrypted = encrypt_string(&config_json, &test_key()).unwrap(); - repo.plugins.lock().unwrap().push(ChannelPluginRow { + repo.plugins.lock().unwrap().push(ChannelConnectionRow { id: "telegram".into(), owner_user_id: OWNER_ID.into(), - r#type: "telegram".into(), + plugin_key: "telegram".into(), name: "Telegram Bot".into(), enabled: false, config: encrypted, @@ -1391,10 +1529,10 @@ mod tests { let config_json = serde_json::to_string(&make_plugin_config()).unwrap(); let encrypted = encrypt_string(&config_json, &test_key()).unwrap(); - repo.plugins.lock().unwrap().push(ChannelPluginRow { + repo.plugins.lock().unwrap().push(ChannelConnectionRow { id: "telegram".into(), owner_user_id: OWNER_ID.into(), - r#type: "telegram".into(), + plugin_key: "telegram".into(), name: "Telegram Bot".into(), enabled: true, config: encrypted, @@ -1419,10 +1557,10 @@ mod tests { // One valid plugin and one with bad encrypted config { let mut plugins = repo.plugins.lock().unwrap(); - plugins.push(ChannelPluginRow { + plugins.push(ChannelConnectionRow { id: "telegram".into(), owner_user_id: OWNER_ID.into(), - r#type: "telegram".into(), + plugin_key: "telegram".into(), name: "Telegram Bot".into(), enabled: true, config: encrypted, @@ -1431,10 +1569,10 @@ mod tests { created_at: now_ms(), updated_at: now_ms(), }); - plugins.push(ChannelPluginRow { + plugins.push(ChannelConnectionRow { id: "lark".into(), owner_user_id: OWNER_ID.into(), - r#type: "lark".into(), + plugin_key: "lark".into(), name: "Lark Bot".into(), enabled: true, config: "invalid-encrypted-data".into(), diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index a35601b16..8822227ba 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -4,7 +4,7 @@ use aionui_ai_agent::{AgentStreamEvent, IWorkerTaskManager}; use aionui_api_types::{AssistantConversationRequest, CreateConversationRequest, SendMessageRequest}; use aionui_common::{AgentType, ConversationSource}; use aionui_conversation::ConversationService; -use aionui_db::models::AssistantSessionRow; +use aionui_db::models::ChannelConversationBindingRow; use tokio::sync::broadcast; use tracing::{debug, info, warn}; @@ -54,7 +54,7 @@ impl ChannelMessageService { pub async fn send_to_agent( &self, owner_user_id: &str, - session: &AssistantSessionRow, + session: &ChannelConversationBindingRow, text: &str, platform: PluginType, ) -> Result { @@ -123,7 +123,7 @@ impl ChannelMessageService { async fn create_conversation_for_session( &self, owner_user_id: &str, - session: &AssistantSessionRow, + session: &ChannelConversationBindingRow, platform: PluginType, ) -> Result { let source = platform_to_source(platform); diff --git a/crates/aionui-channel/src/pairing.rs b/crates/aionui-channel/src/pairing.rs index 545f5ac59..53c5237f6 100644 --- a/crates/aionui-channel/src/pairing.rs +++ b/crates/aionui-channel/src/pairing.rs @@ -1,10 +1,12 @@ use std::sync::Arc; use aionui_api_types::{PairingRequestedPayload, UserAuthorizedPayload, WebSocketMessage}; -use aionui_common::{TimestampMs, generate_id, now_ms}; +use aionui_common::{TimestampMs, generate_id, generate_prefixed_id, now_ms}; use aionui_db::IChannelRepository; -use aionui_db::models::{AssistantUserRow, PairingCodeRow}; +use aionui_db::models::{ChannelPairingRequestRow, ChannelUserRow}; use aionui_realtime::EventBroadcaster; +use hmac::{Hmac, Mac}; +use sha2::Sha256; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; @@ -23,31 +25,67 @@ pub fn generate_pairing_code() -> Result { Ok(format!("{num:0>width$}", width = PAIRING_CODE_LENGTH)) } +/// Server-side keyed hash of a pairing code (hex-encoded HMAC-SHA256). +/// +/// Only this hash is persisted; the plaintext code lives exclusively in the +/// transient flow (IM reply + WebSocket event). The short numeric code is +/// protected against offline brute force by the server-side key. +pub fn pairing_code_hash(code: &str, key: &[u8; 32]) -> String { + let mut mac = Hmac::::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(code.as_bytes()); + let digest = mac.finalize().into_bytes(); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// How an approval/rejection identifies the pairing request: +/// by the surrogate id (cold-loaded pending list) or by the plaintext code +/// (transient WS event / manual entry), which is hashed for lookup. +#[derive(Debug, Clone)] +pub enum PairingSelector<'a> { + Id(&'a str), + Code(&'a str), +} + /// Service for managing pairing authorization flow. /// /// Handles: -/// - Pairing code generation and creation +/// - Pairing code generation and creation (hashed at rest) /// - Approval / rejection of pairing requests /// - Periodic cleanup of expired codes /// - Event broadcasting to WebSocket clients pub struct PairingService { repo: Arc, broadcaster: Arc, + /// Key for `pairing_code_hash`; shares the channel credential key. + code_hash_key: [u8; 32], } impl PairingService { - pub fn new(repo: Arc, broadcaster: Arc) -> Self { - Self { repo, broadcaster } + pub fn new( + repo: Arc, + broadcaster: Arc, + code_hash_key: [u8; 32], + ) -> Self { + Self { + repo, + broadcaster, + code_hash_key, + } } /// Creates a pairing request for an IM user. /// - /// Generates a 6-digit code, stores it with a 10-minute TTL, and + /// Generates a 6-digit code, stores its HMAC with a 10-minute TTL, and /// broadcasts a `channel.pairing-requested` event to all WebSocket - /// clients. + /// clients (the event carries the transient plaintext code). /// - /// If the same platform user already has a pending code, that code is - /// marked as expired before creating the new one. + /// The request attaches to the owner's connection for the platform; a + /// platform without a configured connection cannot pair. If the same + /// platform user already has a pending code, it is expired first. pub async fn request_pairing( &self, owner_user_id: &str, @@ -55,23 +93,34 @@ impl PairingService { platform_type: &str, display_name: Option<&str>, ) -> Result { + let connection = self + .repo + .get_connection_by_plugin_key(owner_user_id, platform_type) + .await? + .ok_or_else(|| ChannelError::PluginNotFound(platform_type.to_owned()))?; + // Expire any existing pending codes for this user - self.expire_user_pending_codes(owner_user_id, platform_user_id, platform_type) + self.repo + .expire_pending_pairings_for_user(owner_user_id, &connection.id, platform_user_id) .await?; let code = generate_pairing_code()?; let now = now_ms(); let expires_at = now + PAIRING_CODE_TTL.as_millis() as TimestampMs; - let row = PairingCodeRow { - code: code.clone(), + let request_id = generate_prefixed_id("pair"); + let row = ChannelPairingRequestRow { + id: request_id.clone(), owner_user_id: owner_user_id.to_owned(), + connection_id: connection.id.clone(), platform_user_id: platform_user_id.to_owned(), platform_type: platform_type.to_owned(), display_name: display_name.map(String::from), + code_hash: pairing_code_hash(&code, &self.code_hash_key), + status: PairingStatus::Pending.to_string(), requested_at: now, expires_at, - status: PairingStatus::Pending.to_string(), + approved_channel_user_id: None, }; self.repo.create_pairing(owner_user_id, &row).await?; @@ -80,12 +129,15 @@ impl PairingService { owner_user_id = %owner_user_id, platform_user_id = %platform_user_id, platform_type = %platform_type, + connection_id = %connection.id, + pairing_id = %request_id, "pairing code created" ); - // Broadcast event + // Broadcast event (transient plaintext code + addressable id) let payload = PairingRequestedPayload { user_id: owner_user_id.to_owned(), + id: request_id, code: code.clone(), platform_user_id: platform_user_id.to_owned(), platform_type: platform_type.to_owned(), @@ -99,46 +151,65 @@ impl PairingService { Ok(code) } - /// Approves a pending pairing code. + /// Approves a pending pairing request (by id or code). /// - /// - Validates the code exists and is still pending + not expired - /// - Creates an `assistant_users` record - /// - Updates the pairing status to `approved` + /// - Validates the request exists and is still pending + not expired + /// - Creates (or reactivates) the `channel_users` record + /// - Updates the pairing status to `approved`, recording the user /// - Broadcasts a `channel.user-authorized` event - pub async fn approve_pairing(&self, owner_user_id: &str, code: &str) -> Result<(), ChannelError> { - let row = self.get_valid_pending_pairing(owner_user_id, code).await?; + pub async fn approve_pairing( + &self, + owner_user_id: &str, + selector: PairingSelector<'_>, + ) -> Result<(), ChannelError> { + let row = self.get_valid_pending_pairing(owner_user_id, selector).await?; let now = now_ms(); - // Create user record + // Create user record bound to the pairing request's connection let user_id = generate_id(); - let user_row = AssistantUserRow { + let user_row = ChannelUserRow { id: user_id.clone(), owner_user_id: owner_user_id.to_owned(), + connection_id: row.connection_id.clone(), platform_user_id: row.platform_user_id.clone(), platform_type: row.platform_type.clone(), display_name: row.display_name.clone(), + status: "active".into(), + revoked_at: None, authorized_at: now, last_active: None, - session_id: None, }; self.repo.create_user(owner_user_id, &user_row).await?; - // Update pairing status + // The created id may differ when a revoked row was reactivated — + // resolve the effective row for the event + audit linkage. + let effective = self + .repo + .get_user_by_platform(owner_user_id, &row.platform_user_id, &row.platform_type) + .await? + .ok_or_else(|| ChannelError::PairingNotFound(row.id.clone()))?; + self.repo - .update_pairing_status(owner_user_id, code, &PairingStatus::Approved.to_string()) + .update_pairing_status( + owner_user_id, + &row.id, + &PairingStatus::Approved.to_string(), + Some(&effective.id), + ) .await?; info!( owner_user_id = %owner_user_id, - user_id = %user_id, + user_id = %effective.id, platform_user_id = %row.platform_user_id, + pairing_id = %row.id, "pairing approved, user created" ); // Broadcast event let payload = UserAuthorizedPayload { user_id: owner_user_id.to_owned(), - id: user_id, + id: effective.id, platform_user_id: row.platform_user_id, platform_type: row.platform_type, display_name: row.display_name, @@ -150,27 +221,30 @@ impl PairingService { Ok(()) } - /// Rejects a pending pairing code. + /// Rejects a pending pairing request (by id or code). /// - /// Validates the code exists and is still pending (not expired or + /// Validates the request exists and is still pending (not expired or /// already processed), then marks it as rejected. - pub async fn reject_pairing(&self, owner_user_id: &str, code: &str) -> Result<(), ChannelError> { - let _row = self.get_valid_pending_pairing(owner_user_id, code).await?; + pub async fn reject_pairing(&self, owner_user_id: &str, selector: PairingSelector<'_>) -> Result<(), ChannelError> { + let row = self.get_valid_pending_pairing(owner_user_id, selector).await?; self.repo - .update_pairing_status(owner_user_id, code, &PairingStatus::Rejected.to_string()) + .update_pairing_status(owner_user_id, &row.id, &PairingStatus::Rejected.to_string(), None) .await?; - info!(owner_user_id = %owner_user_id, "pairing rejected"); + info!(owner_user_id = %owner_user_id, pairing_id = %row.id, "pairing rejected"); Ok(()) } /// Returns all pending (not expired) pairing requests. - pub async fn get_pending_pairings(&self, owner_user_id: &str) -> Result, ChannelError> { + pub async fn get_pending_pairings( + &self, + owner_user_id: &str, + ) -> Result, ChannelError> { let rows = self.repo.get_pending_pairings(owner_user_id).await?; let now = now_ms(); // Filter out expired ones that haven't been cleaned up yet - let active: Vec = rows.into_iter().filter(|r| r.expires_at > now).collect(); + let active: Vec = rows.into_iter().filter(|r| r.expires_at > now).collect(); Ok(active) } @@ -226,16 +300,32 @@ impl PairingService { }) } - /// Validates that a pairing code exists, is pending, and not expired. - async fn get_valid_pending_pairing(&self, owner_user_id: &str, code: &str) -> Result { - let row = self - .repo - .get_pairing_by_code(owner_user_id, code) - .await? - .ok_or_else(|| ChannelError::PairingNotFound(code.to_owned()))?; + /// Resolves a pending pairing request from a selector and validates it + /// is still pending and not expired. + async fn get_valid_pending_pairing( + &self, + owner_user_id: &str, + selector: PairingSelector<'_>, + ) -> Result { + let row = match selector { + PairingSelector::Id(id) => self + .repo + .get_pairing(owner_user_id, id) + .await? + .ok_or_else(|| ChannelError::PairingNotFound(id.to_owned()))?, + PairingSelector::Code(code) => { + let hash = pairing_code_hash(code, &self.code_hash_key); + self.repo + .get_pending_pairing_by_code_hash(owner_user_id, &hash) + .await? + // The plaintext code is not persisted; report the lookup + // failure without echoing the code itself. + .ok_or_else(|| ChannelError::PairingNotFound("".to_owned()))? + } + }; if row.status != PairingStatus::Pending.to_string() { - return Err(ChannelError::PairingAlreadyProcessed(code.to_owned())); + return Err(ChannelError::PairingAlreadyProcessed(row.id.clone())); } let now = now_ms(); @@ -243,45 +333,20 @@ impl PairingService { // Mark as expired for consistency let _ = self .repo - .update_pairing_status(owner_user_id, code, &PairingStatus::Expired.to_string()) + .update_pairing_status(owner_user_id, &row.id, &PairingStatus::Expired.to_string(), None) .await; - return Err(ChannelError::PairingExpired(code.to_owned())); + return Err(ChannelError::PairingExpired(row.id.clone())); } Ok(row) } - - /// Expires any pending codes for the given platform user. - /// - /// Called before creating a new code to ensure only one active code - /// per user at a time. - async fn expire_user_pending_codes( - &self, - owner_user_id: &str, - platform_user_id: &str, - platform_type: &str, - ) -> Result<(), ChannelError> { - let pending = self.repo.get_pending_pairings(owner_user_id).await?; - for row in pending { - if row.platform_user_id == platform_user_id && row.platform_type == platform_type { - self.repo - .update_pairing_status(owner_user_id, &row.code, &PairingStatus::Expired.to_string()) - .await?; - debug!( - owner_user_id = %owner_user_id, - "expired old pending code for user" - ); - } - } - Ok(()) - } } #[cfg(test)] mod tests { use super::*; - use aionui_db::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow}; - use aionui_db::{DbError, IChannelRepository, UpdatePluginStatusParams}; + use aionui_db::models::{ChannelConnectionRow, ChannelConversationBindingRow}; + use aionui_db::{DbError, IChannelRepository, UpdateConnectionStatusParams}; use std::sync::Mutex; // ── Mock EventBroadcaster ────────────────────────────────────────── @@ -312,56 +377,124 @@ mod tests { // ── Mock IChannelRepository ──────────────────────────────────────── struct MockRepo { - pairings: Mutex>, - users: Mutex>, + connections: Mutex>, + pairings: Mutex>, + users: Mutex>, } impl MockRepo { + /// Starts with one connection per platform the tests pair on — + /// pairing now resolves a connection by plugin key and refuses + /// platforms that have none. fn new() -> Self { + let connections = ["telegram", "lark", "dingtalk"] + .into_iter() + .map(|plugin_key| ChannelConnectionRow { + id: format!("conn-{plugin_key}"), + owner_user_id: OWNER_ID.into(), + plugin_key: plugin_key.into(), + name: format!("{plugin_key} bot"), + enabled: true, + config: "{}".into(), + status: None, + last_connected: None, + created_at: 0, + updated_at: 0, + }) + .collect(); + Self { + connections: Mutex::new(connections), pairings: Mutex::new(Vec::new()), users: Mutex::new(Vec::new()), } } - fn get_pairings(&self) -> Vec { + fn get_pairings(&self) -> Vec { self.pairings.lock().unwrap().clone() } - fn get_users(&self) -> Vec { + fn get_users(&self) -> Vec { self.users.lock().unwrap().clone() } + + /// Finds the request whose stored hash matches `code`'s hash. + fn find_by_code(&self, code: &str) -> Option { + let wanted = hash(code); + self.get_pairings().into_iter().find(|p| p.code_hash == wanted) + } + + /// Resolves the platform of a request through its connection, the + /// way the SQL implementation's JOIN does. + fn platform_of(&self, connection_id: &str) -> String { + self.connections + .lock() + .unwrap() + .iter() + .find(|c| c.id == connection_id) + .map(|c| c.plugin_key.clone()) + .unwrap_or_default() + } } #[async_trait::async_trait] impl IChannelRepository for MockRepo { - // -- Plugin CRUD (unused stubs) -- + // -- Connection CRUD -- - async fn get_all_plugins(&self, _owner_user_id: &str) -> Result, DbError> { - Ok(vec![]) + async fn get_all_connections(&self, _owner_user_id: &str) -> Result, DbError> { + Ok(self.connections.lock().unwrap().clone()) } - async fn get_plugin(&self, _owner_user_id: &str, _id: &str) -> Result, DbError> { - Ok(None) + async fn get_connection( + &self, + _owner_user_id: &str, + id: &str, + ) -> Result, DbError> { + Ok(self.connections.lock().unwrap().iter().find(|c| c.id == id).cloned()) } - async fn upsert_plugin(&self, _owner_user_id: &str, _row: &ChannelPluginRow) -> Result<(), DbError> { + + async fn get_connection_by_plugin_key( + &self, + _owner_user_id: &str, + plugin_key: &str, + ) -> Result, DbError> { + Ok(self + .connections + .lock() + .unwrap() + .iter() + .find(|c| c.plugin_key == plugin_key) + .cloned()) + } + async fn upsert_connection(&self, _owner_user_id: &str, row: &ChannelConnectionRow) -> Result<(), DbError> { + let mut connections = self.connections.lock().unwrap(); + connections.retain(|c| c.id != row.id); + connections.push(row.clone()); Ok(()) } - async fn update_plugin_status( + async fn update_connection_status( &self, _owner_user_id: &str, _id: &str, - _params: &UpdatePluginStatusParams, + _params: &UpdateConnectionStatusParams, ) -> Result<(), DbError> { Ok(()) } - async fn delete_plugin(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { + async fn delete_connection(&self, _owner_user_id: &str, id: &str) -> Result<(), DbError> { + self.connections.lock().unwrap().retain(|c| c.id != id); Ok(()) } // -- User CRUD -- - async fn get_all_users(&self, _owner_user_id: &str) -> Result, DbError> { - Ok(self.users.lock().unwrap().clone()) + async fn get_all_users(&self, _owner_user_id: &str) -> Result, DbError> { + Ok(self + .users + .lock() + .unwrap() + .iter() + .filter(|u| u.status == "active") + .cloned() + .collect()) } async fn get_user_by_platform( @@ -369,24 +502,39 @@ mod tests { _owner_user_id: &str, platform_user_id: &str, platform_type: &str, - ) -> Result, DbError> { + ) -> Result, DbError> { let users = self.users.lock().unwrap(); Ok(users .iter() - .find(|u| u.platform_user_id == platform_user_id && u.platform_type == platform_type) + .find(|u| { + u.status == "active" + && u.platform_user_id == platform_user_id + && self.platform_of(&u.connection_id) == platform_type + }) .cloned()) } - async fn create_user(&self, _owner_user_id: &str, row: &AssistantUserRow) -> Result<(), DbError> { + async fn create_user(&self, _owner_user_id: &str, row: &ChannelUserRow) -> Result<(), DbError> { let mut users = self.users.lock().unwrap(); - if users - .iter() - .any(|u| u.platform_user_id == row.platform_user_id && u.platform_type == row.platform_type) - { - return Err(DbError::Conflict("user already exists".into())); + let existing = users + .iter_mut() + .find(|u| u.connection_id == row.connection_id && u.platform_user_id == row.platform_user_id); + match existing { + Some(u) if u.status == "active" => Err(DbError::Conflict("user already exists".into())), + Some(u) => { + // Reactivate the revoked authorization in place. + u.status = "active".into(); + u.revoked_at = None; + u.display_name = row.display_name.clone(); + u.authorized_at = row.authorized_at; + u.last_active = row.last_active; + Ok(()) + } + None => { + users.push(row.clone()); + Ok(()) + } } - users.push(row.clone()); - Ok(()) } async fn update_user_last_active( @@ -404,33 +552,44 @@ mod tests { } } - async fn delete_user(&self, _owner_user_id: &str, id: &str) -> Result<(), DbError> { + async fn revoke_user(&self, _owner_user_id: &str, id: &str) -> Result<(), DbError> { let mut users = self.users.lock().unwrap(); - let len_before = users.len(); - users.retain(|u| u.id != id); - if users.len() == len_before { - Err(DbError::NotFound(id.into())) - } else { - Ok(()) + match users.iter_mut().find(|u| u.id == id && u.status == "active") { + Some(u) => { + // Soft delete: the audit row stays, marked revoked. + u.status = "revoked".into(); + u.revoked_at = Some(now_ms()); + Ok(()) + } + None => Err(DbError::NotFound(id.into())), } } // -- Session CRUD (unused stubs) -- - async fn get_all_sessions(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_all_sessions(&self, _owner_user_id: &str) -> Result, DbError> { Ok(vec![]) } - async fn get_session(&self, _owner_user_id: &str, _id: &str) -> Result, DbError> { + async fn get_session( + &self, + _owner_user_id: &str, + _id: &str, + ) -> Result, DbError> { Ok(None) } async fn get_or_create_session( &self, - _owner_user_id: &str, + owner_user_id: &str, _user_id: &str, _chat_id: &str, - new_row: &AssistantSessionRow, - ) -> Result { - Ok(new_row.clone()) + new_row: &ChannelConversationBindingRow, + ) -> Result { + // Mirror the real INSERT: identity comes from the channel user. + Ok(ChannelConversationBindingRow { + owner_user_id: owner_user_id.to_owned(), + connection_id: STUB_CONNECTION_ID.to_owned(), + ..new_row.clone() + }) } async fn update_session_activity( &self, @@ -448,14 +607,6 @@ mod tests { ) -> Result<(), DbError> { Ok(()) } - async fn update_session_agent_type( - &self, - _owner_user_id: &str, - _id: &str, - _agent_type: &str, - ) -> Result<(), DbError> { - Ok(()) - } async fn delete_sessions_by_user(&self, _owner_user_id: &str, _user_id: &str) -> Result<(), DbError> { Ok(()) } @@ -468,41 +619,85 @@ mod tests { Ok(()) } - // -- Pairing codes -- + // -- Pairing requests -- - async fn create_pairing(&self, _owner_user_id: &str, row: &PairingCodeRow) -> Result<(), DbError> { + async fn create_pairing(&self, _owner_user_id: &str, row: &ChannelPairingRequestRow) -> Result<(), DbError> { let mut pairings = self.pairings.lock().unwrap(); - if pairings.iter().any(|p| p.code == row.code) { - return Err(DbError::Conflict("duplicate code".into())); + // Mirrors the partial unique indexes: one pending request per + // (connection, external user) and per code hash. + if pairings.iter().any(|p| { + p.status == "pending" + && (p.code_hash == row.code_hash + || (p.connection_id == row.connection_id && p.platform_user_id == row.platform_user_id)) + }) { + return Err(DbError::Conflict("duplicate pending pairing request".into())); } pairings.push(row.clone()); Ok(()) } - async fn get_pending_pairings(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_pending_pairings(&self, _owner_user_id: &str) -> Result, DbError> { let pairings = self.pairings.lock().unwrap(); Ok(pairings.iter().filter(|p| p.status == "pending").cloned().collect()) } - async fn get_pairing_by_code( + async fn get_pairing( &self, _owner_user_id: &str, - code: &str, - ) -> Result, DbError> { + id: &str, + ) -> Result, DbError> { let pairings = self.pairings.lock().unwrap(); - Ok(pairings.iter().find(|p| p.code == code).cloned()) + Ok(pairings.iter().find(|p| p.id == id).cloned()) } - async fn update_pairing_status(&self, _owner_user_id: &str, code: &str, status: &str) -> Result<(), DbError> { + async fn get_pending_pairing_by_code_hash( + &self, + _owner_user_id: &str, + code_hash: &str, + ) -> Result, DbError> { + let pairings = self.pairings.lock().unwrap(); + Ok(pairings + .iter() + .find(|p| p.code_hash == code_hash && p.status == "pending") + .cloned()) + } + + async fn update_pairing_status( + &self, + _owner_user_id: &str, + id: &str, + status: &str, + approved_channel_user_id: Option<&str>, + ) -> Result<(), DbError> { let mut pairings = self.pairings.lock().unwrap(); - if let Some(p) = pairings.iter_mut().find(|p| p.code == code) { + if let Some(p) = pairings.iter_mut().find(|p| p.id == id) { p.status = status.to_owned(); + if let Some(user_id) = approved_channel_user_id { + p.approved_channel_user_id = Some(user_id.to_owned()); + } Ok(()) } else { - Err(DbError::NotFound(code.into())) + Err(DbError::NotFound(id.into())) } } + async fn expire_pending_pairings_for_user( + &self, + _owner_user_id: &str, + connection_id: &str, + external_user_id: &str, + ) -> Result { + let mut pairings = self.pairings.lock().unwrap(); + let mut count = 0u64; + for p in pairings.iter_mut() { + if p.status == "pending" && p.connection_id == connection_id && p.platform_user_id == external_user_id { + p.status = "expired".into(); + count += 1; + } + } + Ok(count) + } + async fn cleanup_expired_pairings(&self, _owner_user_id: &str, now: TimestampMs) -> Result { let mut pairings = self.pairings.lock().unwrap(); let mut count = 0u64; @@ -518,11 +713,19 @@ mod tests { // ── Helpers ──────────────────────────────────────────────────────── const OWNER_ID: &str = "owner-test"; + /// Connection the stub binding CRUD derives its `connection_id` from. + const STUB_CONNECTION_ID: &str = "conn-test"; + /// Fixed key so tests can recompute the hash the service stored. + const TEST_KEY: [u8; 32] = [0x42u8; 32]; + + fn hash(code: &str) -> String { + pairing_code_hash(code, &TEST_KEY) + } fn make_service() -> (PairingService, Arc, Arc) { let repo = Arc::new(MockRepo::new()); let broadcaster = Arc::new(MockBroadcaster::new()); - let svc = PairingService::new(repo.clone(), broadcaster.clone()); + let svc = PairingService::new(repo.clone(), broadcaster.clone(), TEST_KEY); (svc, repo, broadcaster) } @@ -558,6 +761,19 @@ mod tests { assert!(codes.len() > 1); } + // ── pairing_code_hash ────────────────────────────────────────────── + + #[test] + fn code_hash_is_deterministic_and_key_dependent() { + let other_key = [0x11u8; 32]; + assert_eq!(hash("123456"), hash("123456")); + assert_ne!(hash("123456"), hash("123457")); + assert_ne!(hash("123456"), pairing_code_hash("123456", &other_key)); + // Hex-encoded SHA-256 output, and never the plaintext itself. + assert_eq!(hash("123456").len(), 64); + assert_ne!(hash("123456"), "123456"); + } + // ── request_pairing ──────────────────────────────────────────────── #[tokio::test] @@ -571,17 +787,36 @@ mod tests { let pairings = repo.get_pairings(); assert_eq!(pairings.len(), 1); - assert_eq!(pairings[0].code, code); + // Only the hash is persisted; the plaintext never reaches the row. + assert_eq!(pairings[0].code_hash, hash(&code)); + assert_ne!(pairings[0].code_hash, code); + assert_eq!(pairings[0].connection_id, "conn-telegram"); assert_eq!(pairings[0].platform_user_id, "tg_42"); assert_eq!(pairings[0].platform_type, "telegram"); assert_eq!(pairings[0].display_name.as_deref(), Some("Alice")); assert_eq!(pairings[0].status, "pending"); + assert!(!pairings[0].id.is_empty()); + assert_eq!(pairings[0].approved_channel_user_id, None); + } + + #[tokio::test] + async fn request_pairing_without_connection_is_rejected() { + let (svc, repo, _bc) = make_service(); + repo.delete_connection(OWNER_ID, "conn-telegram").await.unwrap(); + + let err = svc + .request_pairing(OWNER_ID, "tg_42", "telegram", None) + .await + .unwrap_err(); + assert!(matches!(err, ChannelError::PluginNotFound(platform) if platform == "telegram")); + assert!(repo.get_pairings().is_empty()); } #[tokio::test] async fn request_pairing_broadcasts_event() { - let (svc, _repo, bc) = make_service(); - svc.request_pairing(OWNER_ID, "tg_42", "telegram", Some("Alice")) + let (svc, repo, bc) = make_service(); + let code = svc + .request_pairing(OWNER_ID, "tg_42", "telegram", Some("Alice")) .await .unwrap(); @@ -591,6 +826,10 @@ mod tests { assert_eq!(events[0].data["platform_user_id"], "tg_42"); assert_eq!(events[0].data["platform_type"], "telegram"); assert_eq!(events[0].data["display_name"], "Alice"); + // The event carries the transient plaintext code plus the id the + // cold-loaded pending list uses. + assert_eq!(events[0].data["code"], code); + assert_eq!(events[0].data["id"], repo.get_pairings()[0].id); } #[tokio::test] @@ -621,9 +860,8 @@ mod tests { assert_ne!(code1, code2); - let pairings = repo.get_pairings(); - let old = pairings.iter().find(|p| p.code == code1).unwrap(); - let new = pairings.iter().find(|p| p.code == code2).unwrap(); + let old = repo.find_by_code(&code1).unwrap(); + let new = repo.find_by_code(&code2).unwrap(); assert_eq!(old.status, "expired"); assert_eq!(new.status, "pending"); } @@ -647,31 +885,77 @@ mod tests { .await .unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); - - // Check pairing status - let pairings = repo.get_pairings(); - let p = pairings.iter().find(|p| p.code == code).unwrap(); - assert_eq!(p.status, "approved"); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); // Check user created let users = repo.get_users(); assert_eq!(users.len(), 1); assert_eq!(users[0].platform_user_id, "tg_42"); - assert_eq!(users[0].platform_type, "telegram"); + assert_eq!(users[0].connection_id, "conn-telegram"); + assert_eq!(users[0].status, "active"); assert_eq!(users[0].display_name.as_deref(), Some("Alice")); + + // Check pairing status, and that it links the user it authorized. + let p = repo.find_by_code(&code).unwrap(); + assert_eq!(p.status, "approved"); + assert_eq!(p.approved_channel_user_id.as_deref(), Some(users[0].id.as_str())); + } + + #[tokio::test] + async fn approve_by_id_selector_works() { + let (svc, repo, _bc) = make_service(); + svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); + let id = repo.get_pairings()[0].id.clone(); + + svc.approve_pairing(OWNER_ID, PairingSelector::Id(&id)).await.unwrap(); + + assert_eq!(repo.get_pairings()[0].status, "approved"); + assert_eq!(repo.get_users().len(), 1); + } + + #[tokio::test] + async fn approve_reactivates_a_revoked_user() { + let (svc, repo, _bc) = make_service(); + let code = svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); + + let first_id = repo.get_users()[0].id.clone(); + repo.revoke_user(OWNER_ID, &first_id).await.unwrap(); + + // Pairing again re-authorizes the same identity rather than + // creating a second authorization row. + let code2 = svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code2)) + .await + .unwrap(); + + let users = repo.get_users(); + assert_eq!(users.len(), 1); + assert_eq!(users[0].id, first_id); + assert_eq!(users[0].status, "active"); + assert_eq!(users[0].revoked_at, None); + + // The second request records the reactivated user, not a new id. + let p = repo.find_by_code(&code2).unwrap(); + assert_eq!(p.approved_channel_user_id.as_deref(), Some(first_id.as_str())); } #[tokio::test] async fn approve_broadcasts_user_authorized() { - let (svc, _repo, bc) = make_service(); + let (svc, repo, bc) = make_service(); let code = svc .request_pairing(OWNER_ID, "tg_42", "telegram", Some("Alice")) .await .unwrap(); bc.take_events(); // clear request event - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); let events = bc.take_events(); assert_eq!(events.len(), 1); @@ -679,23 +963,57 @@ mod tests { assert_eq!(events[0].data["platform_user_id"], "tg_42"); assert_eq!(events[0].data["platform_type"], "telegram"); assert_eq!(events[0].data["display_name"], "Alice"); - assert!(events[0].data["id"].is_string()); + assert_eq!(events[0].data["id"], repo.get_users()[0].id); } #[tokio::test] async fn approve_nonexistent_code_returns_not_found() { let (svc, _repo, _bc) = make_service(); - let err = svc.approve_pairing(OWNER_ID, "000000").await.unwrap_err(); + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Code("000000")) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingNotFound(_))); } #[tokio::test] - async fn approve_already_approved_returns_already_processed() { + async fn approve_nonexistent_id_returns_not_found() { + let (svc, _repo, _bc) = make_service(); + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Id("pair-nope")) + .await + .unwrap_err(); + assert!(matches!(err, ChannelError::PairingNotFound(id) if id == "pair-nope")); + } + + /// A rejected code must not be replayable: the code-hash lookup only + /// resolves pending requests. + #[tokio::test] + async fn approve_rejected_code_returns_not_found() { let (svc, _repo, _bc) = make_service(); let code = svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + svc.reject_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); + + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap_err(); + assert!(matches!(err, ChannelError::PairingNotFound(_))); + } - let err = svc.approve_pairing(OWNER_ID, &code).await.unwrap_err(); + #[tokio::test] + async fn approve_already_approved_returns_already_processed() { + let (svc, repo, _bc) = make_service(); + svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); + let id = repo.get_pairings()[0].id.clone(); + svc.approve_pairing(OWNER_ID, PairingSelector::Id(&id)).await.unwrap(); + + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Id(&id)) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_))); } @@ -703,20 +1021,29 @@ mod tests { async fn approve_expired_code_returns_expired() { let (svc, repo, _bc) = make_service(); // Manually insert an already-expired code - let row = PairingCodeRow { - code: "999999".into(), + let row = ChannelPairingRequestRow { + id: "pair-expired".into(), owner_user_id: OWNER_ID.into(), + connection_id: "conn-telegram".into(), platform_user_id: "u1".into(), platform_type: "telegram".into(), display_name: None, + code_hash: hash("999999"), + status: "pending".into(), requested_at: 1000, expires_at: 1001, // long expired - status: "pending".into(), + approved_channel_user_id: None, }; repo.pairings.lock().unwrap().push(row); - let err = svc.approve_pairing(OWNER_ID, "999999").await.unwrap_err(); + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Code("999999")) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingExpired(_))); + // The stale request is marked expired, and no user was authorized. + assert_eq!(repo.get_pairings()[0].status, "expired"); + assert!(repo.get_users().is_empty()); } // ── reject_pairing ───────────────────────────────────────────────── @@ -726,27 +1053,48 @@ mod tests { let (svc, repo, _bc) = make_service(); let code = svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); - svc.reject_pairing(OWNER_ID, &code).await.unwrap(); + svc.reject_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); - let pairings = repo.get_pairings(); - let p = pairings.iter().find(|p| p.code == code).unwrap(); + let p = repo.find_by_code(&code).unwrap(); assert_eq!(p.status, "rejected"); + // A rejection authorizes nobody. + assert_eq!(p.approved_channel_user_id, None); + assert!(repo.get_users().is_empty()); + } + + #[tokio::test] + async fn reject_by_id_selector_works() { + let (svc, repo, _bc) = make_service(); + svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); + let id = repo.get_pairings()[0].id.clone(); + + svc.reject_pairing(OWNER_ID, PairingSelector::Id(&id)).await.unwrap(); + assert_eq!(repo.get_pairings()[0].status, "rejected"); } #[tokio::test] async fn reject_nonexistent_code_returns_not_found() { let (svc, _repo, _bc) = make_service(); - let err = svc.reject_pairing(OWNER_ID, "000000").await.unwrap_err(); + let err = svc + .reject_pairing(OWNER_ID, PairingSelector::Code("000000")) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingNotFound(_))); } #[tokio::test] async fn reject_already_approved_returns_already_processed() { - let (svc, _repo, _bc) = make_service(); - let code = svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + let (svc, repo, _bc) = make_service(); + svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); + let id = repo.get_pairings()[0].id.clone(); + svc.approve_pairing(OWNER_ID, PairingSelector::Id(&id)).await.unwrap(); - let err = svc.reject_pairing(OWNER_ID, &code).await.unwrap_err(); + let err = svc + .reject_pairing(OWNER_ID, PairingSelector::Id(&id)) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_))); } @@ -760,15 +1108,18 @@ mod tests { svc.request_pairing(OWNER_ID, "u1", "telegram", None).await.unwrap(); // Insert manually expired code - let expired_row = PairingCodeRow { - code: "000001".into(), + let expired_row = ChannelPairingRequestRow { + id: "pair-stale".into(), owner_user_id: OWNER_ID.into(), + connection_id: "conn-lark".into(), platform_user_id: "u2".into(), platform_type: "lark".into(), display_name: None, + code_hash: hash("000001"), + status: "pending".into(), requested_at: 1000, expires_at: 1001, - status: "pending".into(), + approved_channel_user_id: None, }; repo.pairings.lock().unwrap().push(expired_row); @@ -797,12 +1148,34 @@ mod tests { async fn authorized_user_returns_true_after_approval() { let (svc, _repo, _bc) = make_service(); let code = svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); let authorized = svc.is_user_authorized(OWNER_ID, "tg_42", "telegram").await.unwrap(); assert!(authorized); } + #[tokio::test] + async fn revoked_user_is_no_longer_authorized() { + let (svc, repo, _bc) = make_service(); + let code = svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); + + let user_id = repo.get_users()[0].id.clone(); + repo.revoke_user(OWNER_ID, &user_id).await.unwrap(); + + assert!(!svc.is_user_authorized(OWNER_ID, "tg_42", "telegram").await.unwrap()); + assert!( + svc.get_internal_user_id(OWNER_ID, "tg_42", "telegram") + .await + .unwrap() + .is_none() + ); + } + // ── cleanup_expired_pairings (via repo directly) ─────────────────── #[tokio::test] @@ -810,15 +1183,18 @@ mod tests { let (svc, repo, _bc) = make_service(); // Insert manually expired pending code - let expired_row = PairingCodeRow { - code: "111111".into(), + let expired_row = ChannelPairingRequestRow { + id: "pair-stale".into(), owner_user_id: OWNER_ID.into(), + connection_id: "conn-telegram".into(), platform_user_id: "u1".into(), platform_type: "telegram".into(), display_name: None, + code_hash: hash("111111"), + status: "pending".into(), requested_at: 1000, expires_at: 2000, - status: "pending".into(), + approved_channel_user_id: None, }; repo.pairings.lock().unwrap().push(expired_row); @@ -828,8 +1204,7 @@ mod tests { let count = repo.cleanup_expired_pairings(OWNER_ID, now_ms()).await.unwrap(); assert_eq!(count, 1); - let pairings = repo.get_pairings(); - let expired = pairings.iter().find(|p| p.code == "111111").unwrap(); + let expired = repo.find_by_code("111111").unwrap(); assert_eq!(expired.status, "expired"); } } diff --git a/crates/aionui-channel/src/plugins/dingtalk/plugin.rs b/crates/aionui-channel/src/plugins/dingtalk/plugin.rs index 43814ea19..fea593d32 100644 --- a/crates/aionui-channel/src/plugins/dingtalk/plugin.rs +++ b/crates/aionui-channel/src/plugins/dingtalk/plugin.rs @@ -690,6 +690,7 @@ async fn handle_bot_message(data_str: &str, message_tx: &mpsc::Sender = rows .into_iter() .map(|r| PairingRequestResponse { - code: r.code, + id: r.id, platform_user_id: r.platform_user_id, platform_type: r.platform_type, display_name: r.display_name, @@ -467,6 +467,18 @@ async fn get_pending_pairings( Ok(Json(ApiResponse::ok(responses))) } +/// Resolves the pairing selector from a request carrying `id` and/or `code`. +/// `id` wins when both are present; neither is a 400. +fn pairing_selector<'a>(id: &'a Option, code: &'a Option) -> Result, ApiError> { + if let Some(id) = id.as_deref().filter(|value| !value.is_empty()) { + return Ok(PairingSelector::Id(id)); + } + if let Some(code) = code.as_deref().filter(|value| !value.is_empty()) { + return Ok(PairingSelector::Code(code)); + } + Err(ApiError::BadRequest("Either 'id' or 'code' is required".into())) +} + /// `POST /api/channel/pairings/approve` — approve a pairing request. async fn approve_pairing( State(state): State, @@ -475,7 +487,8 @@ async fn approve_pairing( ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - state.pairing_service.approve_pairing(&user.id, &req.code).await?; + let selector = pairing_selector(&req.id, &req.code)?; + state.pairing_service.approve_pairing(&user.id, selector).await?; Ok(Json(ApiResponse::ok(BridgeResponse { success: true, @@ -492,7 +505,8 @@ async fn reject_pairing( ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - state.pairing_service.reject_pairing(&user.id, &req.code).await?; + let selector = pairing_selector(&req.id, &req.code)?; + state.pairing_service.reject_pairing(&user.id, selector).await?; Ok(Json(ApiResponse::ok(BridgeResponse { success: true, @@ -539,16 +553,17 @@ async fn revoke_user( ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; - // Clean up sessions first + // Clean up in-memory sessions first state .session_manager .cleanup_user_sessions(&user.id, &req.user_id) .await?; - // Delete user record + // Soft-delete the authorization (audit row retained) and remove the + // user's persisted sessions. state .repo - .delete_user(&user.id, &req.user_id) + .revoke_user(&user.id, &req.user_id) .await .map_err(db_error_to_api_error)?; @@ -574,9 +589,10 @@ async fn get_active_sessions( .map(|r| ChannelSessionResponse { id: r.id, user_id: r.user_id, - agent_type: r.agent_type, + // Deprecated: agent config is no longer part of the binding. + agent_type: None, conversation_id: r.conversation_id, - workspace: r.workspace, + workspace: None, chat_id: r.chat_id, created_at: r.created_at, last_activity: r.last_activity, diff --git a/crates/aionui-channel/src/session.rs b/crates/aionui-channel/src/session.rs index 1d895b80a..1d0313173 100644 --- a/crates/aionui-channel/src/session.rs +++ b/crates/aionui-channel/src/session.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use aionui_common::{generate_id, now_ms}; use aionui_db::IChannelRepository; -use aionui_db::models::AssistantSessionRow; +use aionui_db::models::ChannelConversationBindingRow; use tracing::{debug, info}; use crate::error::ChannelError; @@ -25,26 +25,26 @@ impl SessionManager { /// Finds an existing session for the user+chat pair, or creates one. /// /// - If found: updates `last_activity` and returns the existing session. - /// - If not found: creates a new session with the given `agent_type`. + /// - If not found: creates a fresh binding for the pair. /// - /// The `workspace` parameter is optional and may be set later by - /// the `ChannelManager` when it knows the active workspace path. + /// Agent configuration is not part of the binding: it is resolved per + /// turn from channel settings and the conversation snapshot. pub async fn get_or_create_session( &self, owner_user_id: &str, user_id: &str, chat_id: &str, - agent_type: &str, - workspace: Option<&str>, - ) -> Result { + ) -> Result { let now = now_ms(); - let new_row = AssistantSessionRow { + let new_row = ChannelConversationBindingRow { id: generate_id(), + owner_user_id: owner_user_id.to_owned(), + // The repository INSERT derives the real connection id from the + // active `channel_users` row, so the caller never supplies one. + connection_id: String::new(), user_id: user_id.to_owned(), - agent_type: agent_type.to_owned(), - conversation_id: None, - workspace: workspace.map(String::from), chat_id: Some(chat_id.to_owned()), + conversation_id: None, created_at: now, last_activity: now, }; @@ -65,7 +65,10 @@ impl SessionManager { } /// Returns all active sessions. - pub async fn get_active_sessions(&self, owner_user_id: &str) -> Result, ChannelError> { + pub async fn get_active_sessions( + &self, + owner_user_id: &str, + ) -> Result, ChannelError> { let sessions = self.repo.get_all_sessions(owner_user_id).await?; Ok(sessions) } @@ -79,9 +82,7 @@ impl SessionManager { owner_user_id: &str, user_id: &str, chat_id: &str, - agent_type: &str, - workspace: Option<&str>, - ) -> Result { + ) -> Result { // Delete old session if it exists self.repo .delete_session_by_user_chat(owner_user_id, user_id, chat_id) @@ -89,13 +90,15 @@ impl SessionManager { // Create a fresh session let now = now_ms(); - let new_row = AssistantSessionRow { + let new_row = ChannelConversationBindingRow { id: generate_id(), + owner_user_id: owner_user_id.to_owned(), + // Derived by the repository INSERT from the active + // `channel_users` row — see `get_or_create_session`. + connection_id: String::new(), user_id: user_id.to_owned(), - agent_type: agent_type.to_owned(), - conversation_id: None, - workspace: workspace.map(String::from), chat_id: Some(chat_id.to_owned()), + conversation_id: None, created_at: now, last_activity: now, }; @@ -115,25 +118,6 @@ impl SessionManager { Ok(session) } - /// Updates the agent_type for an existing session. - pub async fn update_agent_type( - &self, - owner_user_id: &str, - session_id: &str, - agent_type: &str, - ) -> Result<(), ChannelError> { - self.repo - .update_session_agent_type(owner_user_id, session_id, agent_type) - .await?; - - debug!( - session_id = %session_id, - agent_type = %agent_type, - "session agent_type updated" - ); - Ok(()) - } - /// Removes all sessions belonging to a user. /// /// Called when a user is revoked to clean up their session state. @@ -166,7 +150,7 @@ impl SessionManager { &self, owner_user_id: &str, session_id: &str, - ) -> Result, ChannelError> { + ) -> Result, ChannelError> { Ok(self.repo.get_session(owner_user_id, session_id).await?) } @@ -197,15 +181,20 @@ impl SessionManager { mod tests { use super::*; use aionui_common::TimestampMs; - use aionui_db::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow}; - use aionui_db::{DbError, IChannelRepository, UpdatePluginStatusParams}; + use aionui_db::models::{ + ChannelConnectionRow, ChannelConversationBindingRow, ChannelPairingRequestRow, ChannelUserRow, + }; + use aionui_db::{DbError, IChannelRepository, UpdateConnectionStatusParams}; use std::sync::Mutex; // ── Mock IChannelRepository ──────────────────────────────────────── const OWNER_ID: &str = "owner-test"; + /// The connection the mock's single authorized channel user hangs off. + /// Stands in for the `channel_users` row the real INSERT derives from. + const CONNECTION_ID: &str = "conn-test"; struct MockRepo { - sessions: Mutex>, + sessions: Mutex>, } impl MockRepo { @@ -215,7 +204,7 @@ mod tests { } } - fn get_sessions(&self) -> Vec { + fn get_sessions(&self) -> Vec { self.sessions.lock().unwrap().clone() } } @@ -223,29 +212,41 @@ mod tests { #[async_trait::async_trait] impl IChannelRepository for MockRepo { // -- Plugin CRUD (unused stubs) -- - async fn get_all_plugins(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_all_connections(&self, _owner_user_id: &str) -> Result, DbError> { Ok(vec![]) } - async fn get_plugin(&self, _owner_user_id: &str, _id: &str) -> Result, DbError> { + async fn get_connection( + &self, + _owner_user_id: &str, + _id: &str, + ) -> Result, DbError> { + Ok(None) + } + + async fn get_connection_by_plugin_key( + &self, + _owner_user_id: &str, + _plugin_key: &str, + ) -> Result, DbError> { Ok(None) } - async fn upsert_plugin(&self, _owner_user_id: &str, _row: &ChannelPluginRow) -> Result<(), DbError> { + async fn upsert_connection(&self, _owner_user_id: &str, _row: &ChannelConnectionRow) -> Result<(), DbError> { Ok(()) } - async fn update_plugin_status( + async fn update_connection_status( &self, _owner_user_id: &str, _id: &str, - _params: &UpdatePluginStatusParams, + _params: &UpdateConnectionStatusParams, ) -> Result<(), DbError> { Ok(()) } - async fn delete_plugin(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { + async fn delete_connection(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { Ok(()) } // -- User CRUD (unused stubs) -- - async fn get_all_users(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_all_users(&self, _owner_user_id: &str) -> Result, DbError> { Ok(vec![]) } async fn get_user_by_platform( @@ -253,10 +254,10 @@ mod tests { _owner_user_id: &str, _platform_user_id: &str, _platform_type: &str, - ) -> Result, DbError> { + ) -> Result, DbError> { Ok(None) } - async fn create_user(&self, _owner_user_id: &str, _row: &AssistantUserRow) -> Result<(), DbError> { + async fn create_user(&self, _owner_user_id: &str, _row: &ChannelUserRow) -> Result<(), DbError> { Ok(()) } async fn update_user_last_active( @@ -267,27 +268,31 @@ mod tests { ) -> Result<(), DbError> { Ok(()) } - async fn delete_user(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { + async fn revoke_user(&self, _owner_user_id: &str, _id: &str) -> Result<(), DbError> { Ok(()) } - // -- Session CRUD -- - async fn get_all_sessions(&self, _owner_user_id: &str) -> Result, DbError> { + // -- Conversation binding CRUD -- + async fn get_all_sessions(&self, _owner_user_id: &str) -> Result, DbError> { Ok(self.sessions.lock().unwrap().clone()) } - async fn get_session(&self, _owner_user_id: &str, id: &str) -> Result, DbError> { + async fn get_session( + &self, + _owner_user_id: &str, + id: &str, + ) -> Result, DbError> { let sessions = self.sessions.lock().unwrap(); Ok(sessions.iter().find(|s| s.id == id).cloned()) } async fn get_or_create_session( &self, - _owner_user_id: &str, + owner_user_id: &str, user_id: &str, chat_id: &str, - new_row: &AssistantSessionRow, - ) -> Result { + new_row: &ChannelConversationBindingRow, + ) -> Result { let mut sessions = self.sessions.lock().unwrap(); // Look for existing session by user_id + chat_id if let Some(existing) = sessions @@ -297,9 +302,15 @@ mod tests { existing.last_activity = new_row.last_activity; return Ok(existing.clone()); } - // Create new - sessions.push(new_row.clone()); - Ok(new_row.clone()) + // Mirror the real INSERT: owner/connection come from the channel + // user row, never from the caller-supplied binding. + let created = ChannelConversationBindingRow { + owner_user_id: owner_user_id.to_owned(), + connection_id: CONNECTION_ID.to_owned(), + ..new_row.clone() + }; + sessions.push(created.clone()); + Ok(created) } async fn update_session_activity( @@ -333,22 +344,6 @@ mod tests { } } - async fn update_session_agent_type( - &self, - _owner_user_id: &str, - id: &str, - agent_type: &str, - ) -> Result<(), DbError> { - let mut sessions = self.sessions.lock().unwrap(); - if let Some(s) = sessions.iter_mut().find(|s| s.id == id) { - s.agent_type = agent_type.to_owned(); - s.last_activity = aionui_common::now_ms(); - Ok(()) - } else { - Err(DbError::NotFound(id.into())) - } - } - async fn delete_sessions_by_user(&self, _owner_user_id: &str, user_id: &str) -> Result<(), DbError> { let mut sessions = self.sessions.lock().unwrap(); sessions.retain(|s| s.user_id != user_id); @@ -366,23 +361,44 @@ mod tests { Ok(()) } - // -- Pairing codes (unused stubs) -- - async fn create_pairing(&self, _owner_user_id: &str, _row: &PairingCodeRow) -> Result<(), DbError> { + // -- Pairing requests (unused stubs) -- + async fn create_pairing(&self, _owner_user_id: &str, _row: &ChannelPairingRequestRow) -> Result<(), DbError> { Ok(()) } - async fn get_pending_pairings(&self, _owner_user_id: &str) -> Result, DbError> { + async fn get_pending_pairings(&self, _owner_user_id: &str) -> Result, DbError> { Ok(vec![]) } - async fn get_pairing_by_code( + async fn get_pairing( &self, _owner_user_id: &str, - _code: &str, - ) -> Result, DbError> { + _id: &str, + ) -> Result, DbError> { Ok(None) } - async fn update_pairing_status(&self, _owner_user_id: &str, _code: &str, _status: &str) -> Result<(), DbError> { + async fn get_pending_pairing_by_code_hash( + &self, + _owner_user_id: &str, + _code_hash: &str, + ) -> Result, DbError> { + Ok(None) + } + async fn update_pairing_status( + &self, + _owner_user_id: &str, + _id: &str, + _status: &str, + _approved_channel_user_id: Option<&str>, + ) -> Result<(), DbError> { Ok(()) } + async fn expire_pending_pairings_for_user( + &self, + _owner_user_id: &str, + _connection_id: &str, + _external_user_id: &str, + ) -> Result { + Ok(0) + } async fn cleanup_expired_pairings(&self, _owner_user_id: &str, _now: TimestampMs) -> Result { Ok(0) } @@ -399,32 +415,28 @@ mod tests { #[tokio::test] async fn creates_new_session() { let (mgr, repo) = make_manager(); - let session = mgr - .get_or_create_session(OWNER_ID, "user1", "chat1", "gemini", None) - .await - .unwrap(); + let session = mgr.get_or_create_session(OWNER_ID, "user1", "chat1").await.unwrap(); assert_eq!(session.user_id, "user1"); assert_eq!(session.chat_id.as_deref(), Some("chat1")); - assert_eq!(session.agent_type, "gemini"); assert!(session.conversation_id.is_none()); + // Identity comes back resolved from the channel user row. + assert_eq!(session.owner_user_id, OWNER_ID); + assert_eq!(session.connection_id, CONNECTION_ID); + assert!(!session.connection_id.is_empty()); let all = repo.get_sessions(); assert_eq!(all.len(), 1); + assert_eq!(all[0].owner_user_id, OWNER_ID); + assert_eq!(all[0].connection_id, CONNECTION_ID); } #[tokio::test] async fn reuses_existing_session_for_same_user_chat() { let (mgr, repo) = make_manager(); - let s1 = mgr - .get_or_create_session(OWNER_ID, "user1", "chat1", "gemini", None) - .await - .unwrap(); - let s2 = mgr - .get_or_create_session(OWNER_ID, "user1", "chat1", "gemini", None) - .await - .unwrap(); + let s1 = mgr.get_or_create_session(OWNER_ID, "user1", "chat1").await.unwrap(); + let s2 = mgr.get_or_create_session(OWNER_ID, "user1", "chat1").await.unwrap(); assert_eq!(s1.id, s2.id); assert_eq!(repo.get_sessions().len(), 1); @@ -434,14 +446,8 @@ mod tests { async fn different_chats_get_different_sessions() { let (mgr, repo) = make_manager(); - let s1 = mgr - .get_or_create_session(OWNER_ID, "user1", "chatA", "acp", None) - .await - .unwrap(); - let s2 = mgr - .get_or_create_session(OWNER_ID, "user1", "chatB", "acp", None) - .await - .unwrap(); + let s1 = mgr.get_or_create_session(OWNER_ID, "user1", "chatA").await.unwrap(); + let s2 = mgr.get_or_create_session(OWNER_ID, "user1", "chatB").await.unwrap(); assert_ne!(s1.id, s2.id); assert_eq!(repo.get_sessions().len(), 2); @@ -451,30 +457,13 @@ mod tests { async fn different_users_same_chat_get_different_sessions() { let (mgr, repo) = make_manager(); - let s1 = mgr - .get_or_create_session(OWNER_ID, "user1", "chat1", "gemini", None) - .await - .unwrap(); - let s2 = mgr - .get_or_create_session(OWNER_ID, "user2", "chat1", "gemini", None) - .await - .unwrap(); + let s1 = mgr.get_or_create_session(OWNER_ID, "user1", "chat1").await.unwrap(); + let s2 = mgr.get_or_create_session(OWNER_ID, "user2", "chat1").await.unwrap(); assert_ne!(s1.id, s2.id); assert_eq!(repo.get_sessions().len(), 2); } - #[tokio::test] - async fn session_with_workspace() { - let (mgr, _repo) = make_manager(); - let session = mgr - .get_or_create_session(OWNER_ID, "u1", "c1", "acp", Some("/workspace")) - .await - .unwrap(); - - assert_eq!(session.workspace.as_deref(), Some("/workspace")); - } - // ── get_active_sessions ──────────────────────────────────────────── #[tokio::test] @@ -487,12 +476,8 @@ mod tests { #[tokio::test] async fn get_active_sessions_returns_all() { let (mgr, _repo) = make_manager(); - mgr.get_or_create_session(OWNER_ID, "u1", "c1", "gemini", None) - .await - .unwrap(); - mgr.get_or_create_session(OWNER_ID, "u2", "c2", "acp", None) - .await - .unwrap(); + mgr.get_or_create_session(OWNER_ID, "u1", "c1").await.unwrap(); + mgr.get_or_create_session(OWNER_ID, "u2", "c2").await.unwrap(); let sessions = mgr.get_active_sessions(OWNER_ID).await.unwrap(); assert_eq!(sessions.len(), 2); @@ -503,15 +488,9 @@ mod tests { #[tokio::test] async fn cleanup_removes_user_sessions() { let (mgr, repo) = make_manager(); - mgr.get_or_create_session(OWNER_ID, "u1", "c1", "gemini", None) - .await - .unwrap(); - mgr.get_or_create_session(OWNER_ID, "u1", "c2", "gemini", None) - .await - .unwrap(); - mgr.get_or_create_session(OWNER_ID, "u2", "c1", "acp", None) - .await - .unwrap(); + mgr.get_or_create_session(OWNER_ID, "u1", "c1").await.unwrap(); + mgr.get_or_create_session(OWNER_ID, "u1", "c2").await.unwrap(); + mgr.get_or_create_session(OWNER_ID, "u2", "c1").await.unwrap(); mgr.cleanup_user_sessions(OWNER_ID, "u1").await.unwrap(); @@ -523,9 +502,7 @@ mod tests { #[tokio::test] async fn cleanup_noop_for_unknown_user() { let (mgr, repo) = make_manager(); - mgr.get_or_create_session(OWNER_ID, "u1", "c1", "gemini", None) - .await - .unwrap(); + mgr.get_or_create_session(OWNER_ID, "u1", "c1").await.unwrap(); mgr.cleanup_user_sessions(OWNER_ID, "u999").await.unwrap(); @@ -537,10 +514,7 @@ mod tests { #[tokio::test] async fn bind_conversation_persists_conversation_id() { let (mgr, repo) = make_manager(); - let session = mgr - .get_or_create_session(OWNER_ID, "u1", "c1", "acp", None) - .await - .unwrap(); + let session = mgr.get_or_create_session(OWNER_ID, "u1", "c1").await.unwrap(); assert!(session.conversation_id.is_none()); mgr.bind_conversation(OWNER_ID, &session.id, "conv_123").await.unwrap(); @@ -561,18 +535,18 @@ mod tests { #[tokio::test] async fn reset_session_creates_fresh_session() { let (mgr, repo) = make_manager(); - let s1 = mgr - .get_or_create_session(OWNER_ID, "u1", "c1", "gemini", None) - .await - .unwrap(); + let s1 = mgr.get_or_create_session(OWNER_ID, "u1", "c1").await.unwrap(); - let s2 = mgr.reset_session(OWNER_ID, "u1", "c1", "gemini", None).await.unwrap(); + let s2 = mgr.reset_session(OWNER_ID, "u1", "c1").await.unwrap(); // New session should have a different ID assert_ne!(s1.id, s2.id); assert_eq!(s2.user_id, "u1"); assert_eq!(s2.chat_id.as_deref(), Some("c1")); assert!(s2.conversation_id.is_none()); + // The replacement binding is re-derived, not carried over. + assert_eq!(s2.owner_user_id, OWNER_ID); + assert_eq!(s2.connection_id, CONNECTION_ID); // Only 1 session should exist (old one deleted) assert_eq!(repo.get_sessions().len(), 1); @@ -581,33 +555,11 @@ mod tests { #[tokio::test] async fn reset_session_noop_when_no_existing() { let (mgr, repo) = make_manager(); - let session = mgr.reset_session(OWNER_ID, "u1", "c1", "acp", None).await.unwrap(); + let session = mgr.reset_session(OWNER_ID, "u1", "c1").await.unwrap(); assert_eq!(session.user_id, "u1"); + assert_eq!(session.owner_user_id, OWNER_ID); + assert_eq!(session.connection_id, CONNECTION_ID); assert_eq!(repo.get_sessions().len(), 1); } - - // ── update_agent_type ───────────────────────────────────────────── - - #[tokio::test] - async fn update_agent_type_persists() { - let (mgr, repo) = make_manager(); - let session = mgr - .get_or_create_session(OWNER_ID, "u1", "c1", "gemini", None) - .await - .unwrap(); - assert_eq!(session.agent_type, "gemini"); - - mgr.update_agent_type(OWNER_ID, &session.id, "acp").await.unwrap(); - - let updated = repo.get_sessions().into_iter().find(|s| s.id == session.id).unwrap(); - assert_eq!(updated.agent_type, "acp"); - } - - #[tokio::test] - async fn update_agent_type_not_found() { - let (mgr, _repo) = make_manager(); - let err = mgr.update_agent_type(OWNER_ID, "nonexistent", "acp").await; - assert!(err.is_err()); - } } diff --git a/crates/aionui-channel/src/types.rs b/crates/aionui-channel/src/types.rs index 58fca617e..c7b6abd47 100644 --- a/crates/aionui-channel/src/types.rs +++ b/crates/aionui-channel/src/types.rs @@ -275,6 +275,10 @@ pub struct BotInfo { pub struct UnifiedIncomingMessage { #[serde(default, skip_serializing_if = "Option::is_none")] pub owner_user_id: Option, + /// Connection that received this message. Stamped by the manager's + /// callback bridge; `platform` stays for display and legacy addressing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connection_id: Option, pub id: String, pub platform: PluginType, pub chat_id: String, @@ -719,6 +723,7 @@ mod tests { fn unified_incoming_message_text() { let msg = UnifiedIncomingMessage { owner_user_id: None, + connection_id: None, id: "msg_1".into(), platform: PluginType::Telegram, chat_id: "chat_42".into(), @@ -973,6 +978,7 @@ mod tests { fn incoming_message_roundtrip() { let msg = UnifiedIncomingMessage { owner_user_id: None, + connection_id: None, id: "m1".into(), platform: PluginType::Lark, chat_id: "c1".into(), diff --git a/crates/aionui-channel/tests/manager_integration.rs b/crates/aionui-channel/tests/manager_integration.rs index ab207c62e..788be0f13 100644 --- a/crates/aionui-channel/tests/manager_integration.rs +++ b/crates/aionui-channel/tests/manager_integration.rs @@ -301,9 +301,13 @@ async fn ep1_enable_telegram_plugin() { .unwrap(); // Plugin persisted in DB - let row = repo.get_plugin(OWNER_ID, "telegram").await.unwrap().unwrap(); + let row = repo + .get_connection_by_plugin_key(OWNER_ID, "telegram") + .await + .unwrap() + .unwrap(); assert!(row.enabled); - assert_eq!(row.r#type, "telegram"); + assert_eq!(row.plugin_key, "telegram"); assert_eq!(row.name, "Telegram Bot"); assert!(row.last_connected.is_some()); @@ -336,7 +340,11 @@ async fn ep2_re_enable_updates_config() { assert_eq!(mgr.active_plugin_count(), 1); // Config should be updated - let row = repo.get_plugin(OWNER_ID, "telegram").await.unwrap().unwrap(); + let row = repo + .get_connection_by_plugin_key(OWNER_ID, "telegram") + .await + .unwrap() + .unwrap(); let decrypted = decrypt_string(&row.config, &test_key()).unwrap(); let config: PluginConfig = serde_json::from_str(&decrypted).unwrap(); assert_eq!(config.credentials.token.as_deref(), Some("bot:new_token_456")); @@ -363,7 +371,11 @@ async fn ep6_re_enable_empty_config_reuses_stored_credentials() { assert!(mgr.is_plugin_running(OWNER_ID, "telegram")); - let row = repo.get_plugin(OWNER_ID, "telegram").await.unwrap().unwrap(); + let row = repo + .get_connection_by_plugin_key(OWNER_ID, "telegram") + .await + .unwrap() + .unwrap(); assert!(row.enabled); let decrypted = decrypt_string(&row.config, &test_key()).unwrap(); let config: PluginConfig = serde_json::from_str(&decrypted).unwrap(); @@ -415,7 +427,11 @@ async fn dp1_disable_enabled_plugin() { assert_eq!(mgr.active_plugin_count(), 0); assert!(!mgr.is_plugin_running(OWNER_ID, "telegram")); - let row = repo.get_plugin(OWNER_ID, "telegram").await.unwrap().unwrap(); + let row = repo + .get_connection_by_plugin_key(OWNER_ID, "telegram") + .await + .unwrap() + .unwrap(); assert!(!row.enabled); assert_eq!(row.status.as_deref(), Some("stopped")); } @@ -505,7 +521,7 @@ async fn tp_test_does_not_persist() { .await .unwrap(); - let plugins = repo.get_all_plugins(OWNER_ID).await.unwrap(); + let plugins = repo.get_all_connections(OWNER_ID).await.unwrap(); assert!(plugins.is_empty()); assert_eq!(mgr.active_plugin_count(), 0); } @@ -521,7 +537,11 @@ async fn cs1_credentials_stored_encrypted() { .await .unwrap(); - let row = repo.get_plugin(OWNER_ID, "telegram").await.unwrap().unwrap(); + let row = repo + .get_connection_by_plugin_key(OWNER_ID, "telegram") + .await + .unwrap() + .unwrap(); // Config should not contain plaintext token assert!(!row.config.contains("bot:valid123")); @@ -675,12 +695,14 @@ async fn same_plugin_id_is_runtime_isolated_by_owner() { assert!(mgr.is_plugin_running(OWNER_ID, "telegram")); assert!(mgr.is_plugin_running(&owner_b_id, "telegram")); - let owner_a_plugins = repo.get_all_plugins(OWNER_ID).await.unwrap(); - let owner_b_plugins = repo.get_all_plugins(&owner_b_id).await.unwrap(); + let owner_a_plugins = repo.get_all_connections(OWNER_ID).await.unwrap(); + let owner_b_plugins = repo.get_all_connections(&owner_b_id).await.unwrap(); assert_eq!(owner_a_plugins.len(), 1); assert_eq!(owner_b_plugins.len(), 1); - assert_eq!(owner_a_plugins[0].id, "telegram"); - assert_eq!(owner_b_plugins[0].id, "telegram"); + assert_eq!(owner_a_plugins[0].plugin_key, "telegram"); + assert_eq!(owner_b_plugins[0].plugin_key, "telegram"); + // Each owner gets its own generated connection id. + assert_ne!(owner_a_plugins[0].id, owner_b_plugins[0].id); mgr.disable_plugin(OWNER_ID, "telegram").await.unwrap(); @@ -763,7 +785,11 @@ async fn enable_failure_sets_error_in_db() { assert!(err.is_err()); // Plugin should exist in DB with error status - let row = repo.get_plugin(OWNER_ID, "telegram").await.unwrap().unwrap(); + let row = repo + .get_connection_by_plugin_key(OWNER_ID, "telegram") + .await + .unwrap() + .unwrap(); assert_eq!(row.status.as_deref(), Some("error")); assert_eq!(mgr.active_plugin_count(), 0); } diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index 6920735ed..62f1239b5 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -12,7 +12,7 @@ use aionui_channel::types::PluginType; use aionui_common::{AgentKillReason, AgentType, ConversationStatus, TimestampMs}; use aionui_conversation::ConversationService; use aionui_conversation::skill_resolver::{ResolvedAgentSkill, SkillResolver}; -use aionui_db::models::AssistantSessionRow; +use aionui_db::models::ChannelConversationBindingRow; use aionui_db::models::UpsertAssistantDefinitionParams; use aionui_db::{ IAcpSessionRepository, IAssistantDefinitionRepository, IClientPreferenceRepository, IConversationRepository, @@ -241,12 +241,12 @@ async fn send_to_agent_warms_cold_task_before_returning_stream_subscription() { ))); let message_svc = ChannelMessageService::new(conversation_svc, Arc::clone(&task_manager), settings); - let session = AssistantSessionRow { + let session = ChannelConversationBindingRow { id: "session-1".to_owned(), + owner_user_id: TEST_OWNER_USER_ID.to_owned(), + connection_id: "conn-telegram".to_owned(), user_id: "channel-user-1".to_owned(), - agent_type: "aionrs".to_owned(), conversation_id: None, - workspace: None, chat_id: Some("7088048016".to_owned()), created_at: 1, last_activity: 1, @@ -319,12 +319,12 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() let settings = Arc::new(ChannelSettingsService::new(pref_repo).with_assistant_repos(definition_repo, overlay_repo)); let message_svc = ChannelMessageService::new(conversation_svc, Arc::clone(&task_manager), settings); - let session = AssistantSessionRow { + let session = ChannelConversationBindingRow { id: "session-assisted".to_owned(), + owner_user_id: TEST_OWNER_USER_ID.to_owned(), + connection_id: "conn-telegram".to_owned(), user_id: "channel-user-1".to_owned(), - agent_type: "aionrs".to_owned(), conversation_id: None, - workspace: None, chat_id: Some("7088048016".to_owned()), created_at: 1, last_activity: 1, @@ -396,12 +396,12 @@ async fn send_to_agent_rejects_unresolvable_channel_assistant_binding() { let settings = Arc::new(ChannelSettingsService::new(pref_repo).with_assistant_repos(definition_repo, overlay_repo)); let message_svc = ChannelMessageService::new(conversation_svc, Arc::clone(&task_manager), settings); - let session = AssistantSessionRow { + let session = ChannelConversationBindingRow { id: "session-assisted-missing".to_owned(), + owner_user_id: TEST_OWNER_USER_ID.to_owned(), + connection_id: "conn-telegram".to_owned(), user_id: "channel-user-missing".to_owned(), - agent_type: "aionrs".to_owned(), conversation_id: None, - workspace: None, chat_id: Some("7088048017".to_owned()), created_at: 1, last_activity: 1, @@ -456,12 +456,12 @@ async fn send_to_agent_without_saved_binding_defaults_to_bare_aionrs_assistant() let settings = Arc::new(ChannelSettingsService::new(pref_repo).with_assistant_repos(definition_repo, overlay_repo)); let message_svc = ChannelMessageService::new(conversation_svc, Arc::clone(&task_manager), settings); - let session = AssistantSessionRow { + let session = ChannelConversationBindingRow { id: "session-assisted-default-aionrs".to_owned(), + owner_user_id: TEST_OWNER_USER_ID.to_owned(), + connection_id: "conn-telegram".to_owned(), user_id: "channel-user-default".to_owned(), - agent_type: "aionrs".to_owned(), conversation_id: None, - workspace: None, chat_id: Some("7088048018".to_owned()), created_at: 1, last_activity: 1, @@ -534,12 +534,12 @@ async fn send_to_agent_without_assistant_name_falls_back_to_legacy_channel_name( let settings = Arc::new(ChannelSettingsService::new(pref_repo).with_assistant_repos(definition_repo, overlay_repo)); let message_svc = ChannelMessageService::new(conversation_svc, Arc::clone(&task_manager), settings); - let session = AssistantSessionRow { + let session = ChannelConversationBindingRow { id: "session-assisted-fallback-name".to_owned(), + owner_user_id: TEST_OWNER_USER_ID.to_owned(), + connection_id: "conn-telegram".to_owned(), user_id: "channel-user-2".to_owned(), - agent_type: "aionrs".to_owned(), conversation_id: None, - workspace: None, chat_id: Some("7088048016".to_owned()), created_at: 1, last_activity: 1, diff --git a/crates/aionui-channel/tests/orchestrator_test.rs b/crates/aionui-channel/tests/orchestrator_test.rs index a1527ab34..d8cb134ab 100644 --- a/crates/aionui-channel/tests/orchestrator_test.rs +++ b/crates/aionui-channel/tests/orchestrator_test.rs @@ -13,6 +13,7 @@ const OWNER_ID: &str = "system_default_user"; fn make_text_message(user_id: &str, chat_id: &str, text: &str) -> UnifiedIncomingMessage { UnifiedIncomingMessage { owner_user_id: None, + connection_id: None, id: "msg-1".into(), platform: PluginType::Telegram, chat_id: chat_id.into(), @@ -46,7 +47,26 @@ async fn unauthorized_user_gets_pairing_response() { Arc::new(aionui_db::SqliteClientPreferenceRepository::new(pool)); let settings = Arc::new(ChannelSettingsService::new(pref_repo)); - let pairing = Arc::new(PairingService::new(repo.clone(), bus)); + // Pairing attaches the request to the platform's connection. + repo.upsert_connection( + OWNER_ID, + &aionui_db::models::ChannelConnectionRow { + id: "conn-telegram".into(), + owner_user_id: OWNER_ID.into(), + plugin_key: "telegram".into(), + name: "telegram bot".into(), + enabled: true, + config: "{}".into(), + status: None, + last_connected: None, + created_at: aionui_common::now_ms(), + updated_at: aionui_common::now_ms(), + }, + ) + .await + .unwrap(); + + let pairing = Arc::new(PairingService::new(repo.clone(), bus, [0x42u8; 32])); let session_mgr = Arc::new(SessionManager::new(repo)); let executor = Arc::new(ActionExecutor::new( pairing, diff --git a/crates/aionui-channel/tests/pairing_integration.rs b/crates/aionui-channel/tests/pairing_integration.rs index e1193a3fe..6bae70e06 100644 --- a/crates/aionui-channel/tests/pairing_integration.rs +++ b/crates/aionui-channel/tests/pairing_integration.rs @@ -8,16 +8,59 @@ use std::sync::{Arc, Mutex}; use aionui_api_types::WebSocketMessage; use aionui_common::{TimestampMs, now_ms}; -use aionui_db::models::PairingCodeRow; -use aionui_db::{IChannelRepository, SqliteChannelRepository, init_database_memory}; +use aionui_db::models::{ChannelConnectionRow, ChannelPairingRequestRow}; +use aionui_db::{DbError, IChannelRepository, SqliteChannelRepository, init_database_memory}; use aionui_realtime::EventBroadcaster; use aionui_channel::constants::{PAIRING_CODE_LENGTH, PAIRING_CODE_TTL}; use aionui_channel::error::ChannelError; -use aionui_channel::pairing::PairingService; +use aionui_channel::pairing::{PairingSelector, PairingService, pairing_code_hash}; // ── Test infrastructure ───────────────────────────────────────────── const OWNER_ID: &str = "system_default_user"; +/// Fixed key so tests can recompute the hash the service stored. +const TEST_KEY: [u8; 32] = [0x42u8; 32]; + +fn hash(code: &str) -> String { + pairing_code_hash(code, &TEST_KEY) +} + +/// Connection ids the seeded platforms resolve to. +fn connection_id_for(plugin_key: &str) -> String { + format!("conn-{plugin_key}") +} + +fn make_connection(plugin_key: &str) -> ChannelConnectionRow { + ChannelConnectionRow { + id: connection_id_for(plugin_key), + owner_user_id: OWNER_ID.into(), + plugin_key: plugin_key.into(), + name: format!("{plugin_key} bot"), + enabled: true, + config: "{}".into(), + status: None, + last_connected: None, + created_at: now_ms(), + updated_at: now_ms(), + } +} + +/// Builds a pending request row directly, bypassing the service. +fn make_pairing_row(id: &str, code: &str, platform_user_id: &str, plugin_key: &str) -> ChannelPairingRequestRow { + ChannelPairingRequestRow { + id: id.into(), + owner_user_id: OWNER_ID.into(), + connection_id: connection_id_for(plugin_key), + platform_user_id: platform_user_id.into(), + platform_type: plugin_key.into(), + display_name: None, + code_hash: hash(code), + status: "pending".into(), + requested_at: 1000, + expires_at: 1001, + approved_channel_user_id: None, + } +} struct MockBroadcaster { events: Mutex>>, @@ -46,12 +89,28 @@ async fn setup() -> (PairingService, Arc, Arc = Arc::new(SqliteChannelRepository::new(db.pool().clone())); let bc = Arc::new(MockBroadcaster::new()); - let svc = PairingService::new(repo.clone(), bc.clone()); + let svc = PairingService::new(repo.clone(), bc.clone(), TEST_KEY); + // Pairing resolves a connection by plugin key, so every platform the + // tests pair on needs one. + for plugin_key in ["telegram", "lark"] { + repo.upsert_connection(OWNER_ID, &make_connection(plugin_key)) + .await + .unwrap(); + } // Keep db alive by leaking — test process exits anyway std::mem::forget(db); (svc, repo, bc) } +/// Resolves a still-pending request's surrogate id from its plaintext code. +async fn pending_id(repo: &Arc, code: &str) -> String { + repo.get_pending_pairing_by_code_hash(OWNER_ID, &hash(code)) + .await + .unwrap() + .unwrap() + .id +} + // ── PG-1: Generated code is 6 digits ─────────────────────────────── #[tokio::test] @@ -74,10 +133,17 @@ async fn pg2_code_expires_after_ten_minutes() { let code = svc.request_pairing(OWNER_ID, "u1", "telegram", None).await.unwrap(); let after = now_ms(); - let row = repo.get_pairing_by_code(OWNER_ID, &code).await.unwrap().unwrap(); + let row = repo + .get_pending_pairing_by_code_hash(OWNER_ID, &hash(&code)) + .await + .unwrap() + .unwrap(); let ttl = PAIRING_CODE_TTL.as_millis() as TimestampMs; assert!(row.expires_at >= before + ttl); assert!(row.expires_at <= after + ttl); + // Only the keyed hash is persisted, never the plaintext code. + assert_eq!(row.code_hash, hash(&code)); + assert_ne!(row.code_hash, code); } // ── PG-3: Same user re-request expires old code ──────────────────── @@ -89,6 +155,7 @@ async fn pg3_same_user_re_request_expires_old_code() { .request_pairing(OWNER_ID, "u1", "telegram", Some("Alice")) .await .unwrap(); + let old_id = pending_id(&repo, &code1).await; let code2 = svc .request_pairing(OWNER_ID, "u1", "telegram", Some("Alice")) .await @@ -96,10 +163,21 @@ async fn pg3_same_user_re_request_expires_old_code() { assert_ne!(code1, code2); - let old = repo.get_pairing_by_code(OWNER_ID, &code1).await.unwrap().unwrap(); - let new = repo.get_pairing_by_code(OWNER_ID, &code2).await.unwrap().unwrap(); + let old = repo.get_pairing(OWNER_ID, &old_id).await.unwrap().unwrap(); + let new = repo + .get_pending_pairing_by_code_hash(OWNER_ID, &hash(&code2)) + .await + .unwrap() + .unwrap(); assert_eq!(old.status, "expired"); assert_eq!(new.status, "pending"); + // The superseded code is no longer resolvable for approval. + assert!( + repo.get_pending_pairing_by_code_hash(OWNER_ID, &hash(&code1)) + .await + .unwrap() + .is_none() + ); } // ── PP-1: No pending pairings returns empty ──────────────────────── @@ -133,16 +211,7 @@ async fn pp3_expired_not_in_pending() { svc.request_pairing(OWNER_ID, "u1", "telegram", None).await.unwrap(); // Insert already-expired code directly - let expired_row = PairingCodeRow { - code: "000001".into(), - owner_user_id: OWNER_ID.into(), - platform_user_id: "u2".into(), - platform_type: "lark".into(), - display_name: None, - requested_at: 1000, - expires_at: 1001, - status: "pending".into(), - }; + let expired_row = make_pairing_row("pair-stale", "000001", "u2", "lark"); repo.create_pairing(OWNER_ID, &expired_row).await.unwrap(); let pending = svc.get_pending_pairings(OWNER_ID).await.unwrap(); @@ -160,11 +229,16 @@ async fn ap1_approve_valid_pairing() { .await .unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + let id = pending_id(&repo, &code).await; + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); - // Status updated - let row = repo.get_pairing_by_code(OWNER_ID, &code).await.unwrap().unwrap(); + // Status updated, and the request records the user it authorized. + let row = repo.get_pairing(OWNER_ID, &id).await.unwrap().unwrap(); assert_eq!(row.status, "approved"); + let users = repo.get_all_users(OWNER_ID).await.unwrap(); + assert_eq!(row.approved_channel_user_id.as_deref(), Some(users[0].id.as_str())); } // ── AP-2: Approved user appears in authorized list (DC-2) ────────── @@ -176,12 +250,16 @@ async fn ap2_dc2_approved_user_in_authorized_list() { .request_pairing(OWNER_ID, "tg_42", "telegram", Some("Alice")) .await .unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); let users = repo.get_all_users(OWNER_ID).await.unwrap(); assert_eq!(users.len(), 1); assert_eq!(users[0].platform_user_id, "tg_42"); assert_eq!(users[0].platform_type, "telegram"); + assert_eq!(users[0].connection_id, connection_id_for("telegram")); + assert_eq!(users[0].status, "active"); assert_eq!(users[0].display_name.as_deref(), Some("Alice")); } @@ -190,8 +268,18 @@ async fn ap2_dc2_approved_user_in_authorized_list() { #[tokio::test] async fn ap3_approve_nonexistent_code() { let (svc, _repo, _bc) = setup().await; - let err = svc.approve_pairing(OWNER_ID, "000000").await.unwrap_err(); + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Code("000000")) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingNotFound(_))); + + // Unknown surrogate id is equally rejected. + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Id("pair-nope")) + .await + .unwrap_err(); + assert!(matches!(err, ChannelError::PairingNotFound(id) if id == "pair-nope")); } // ── AP-4: Approve expired code ───────────────────────────────────── @@ -199,22 +287,26 @@ async fn ap3_approve_nonexistent_code() { #[tokio::test] async fn ap4_approve_expired_code() { let (_svc, repo, bc) = setup().await; - let svc = PairingService::new(repo.clone(), bc.clone()); + let svc = PairingService::new(repo.clone(), bc.clone(), TEST_KEY); - let expired_row = PairingCodeRow { - code: "999999".into(), - owner_user_id: OWNER_ID.into(), - platform_user_id: "u1".into(), - platform_type: "telegram".into(), - display_name: None, - requested_at: 1000, - expires_at: 1001, - status: "pending".into(), - }; + let expired_row = make_pairing_row("pair-expired", "999999", "u1", "telegram"); repo.create_pairing(OWNER_ID, &expired_row).await.unwrap(); - let err = svc.approve_pairing(OWNER_ID, "999999").await.unwrap_err(); + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Code("999999")) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingExpired(_))); + // The stale request is marked expired, and nobody was authorized. + assert_eq!( + repo.get_pairing(OWNER_ID, "pair-expired") + .await + .unwrap() + .unwrap() + .status, + "expired" + ); + assert!(repo.get_all_users(OWNER_ID).await.unwrap().is_empty()); } // ── AP-5: Double approve returns already processed ───────────────── @@ -223,9 +315,13 @@ async fn ap4_approve_expired_code() { async fn ap5_double_approve_returns_already_processed() { let (svc, _repo, _bc) = setup().await; let code = svc.request_pairing(OWNER_ID, "u1", "telegram", None).await.unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + let id = pending_id(&_repo, &code).await; + svc.approve_pairing(OWNER_ID, PairingSelector::Id(&id)).await.unwrap(); - let err = svc.approve_pairing(OWNER_ID, &code).await.unwrap_err(); + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Id(&id)) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_))); } @@ -234,7 +330,10 @@ async fn ap5_double_approve_returns_already_processed() { #[tokio::test] async fn ap6_empty_code_returns_not_found() { let (svc, _repo, _bc) = setup().await; - let err = svc.approve_pairing(OWNER_ID, "").await.unwrap_err(); + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Code("")) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingNotFound(_))); } @@ -245,10 +344,16 @@ async fn rp1_reject_valid_pairing() { let (svc, repo, _bc) = setup().await; let code = svc.request_pairing(OWNER_ID, "u1", "telegram", None).await.unwrap(); - svc.reject_pairing(OWNER_ID, &code).await.unwrap(); + let id = pending_id(&repo, &code).await; + svc.reject_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); - let row = repo.get_pairing_by_code(OWNER_ID, &code).await.unwrap().unwrap(); + let row = repo.get_pairing(OWNER_ID, &id).await.unwrap().unwrap(); assert_eq!(row.status, "rejected"); + // A rejection authorizes nobody. + assert_eq!(row.approved_channel_user_id, None); + assert!(repo.get_all_users(OWNER_ID).await.unwrap().is_empty()); } // ── RP-2: Rejected code not in pending list ──────────────────────── @@ -257,7 +362,9 @@ async fn rp1_reject_valid_pairing() { async fn rp2_rejected_not_in_pending() { let (svc, _repo, _bc) = setup().await; let code = svc.request_pairing(OWNER_ID, "u1", "telegram", None).await.unwrap(); - svc.reject_pairing(OWNER_ID, &code).await.unwrap(); + svc.reject_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); let pending = svc.get_pending_pairings(OWNER_ID).await.unwrap(); assert!(pending.is_empty()); @@ -268,7 +375,10 @@ async fn rp2_rejected_not_in_pending() { #[tokio::test] async fn rp3_reject_nonexistent_code() { let (svc, _repo, _bc) = setup().await; - let err = svc.reject_pairing(OWNER_ID, "000000").await.unwrap_err(); + let err = svc + .reject_pairing(OWNER_ID, PairingSelector::Code("000000")) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingNotFound(_))); } @@ -278,9 +388,13 @@ async fn rp3_reject_nonexistent_code() { async fn rp4_reject_already_approved() { let (svc, _repo, _bc) = setup().await; let code = svc.request_pairing(OWNER_ID, "u1", "telegram", None).await.unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + let id = pending_id(&_repo, &code).await; + svc.approve_pairing(OWNER_ID, PairingSelector::Id(&id)).await.unwrap(); - let err = svc.reject_pairing(OWNER_ID, &code).await.unwrap_err(); + let err = svc + .reject_pairing(OWNER_ID, PairingSelector::Id(&id)) + .await + .unwrap_err(); assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_))); } @@ -289,24 +403,15 @@ async fn rp4_reject_already_approved() { #[tokio::test] async fn ec1_expired_codes_cleaned_up() { let (_svc, repo, bc) = setup().await; - let _svc = PairingService::new(repo.clone(), bc.clone()); + let _svc = PairingService::new(repo.clone(), bc.clone(), TEST_KEY); - let expired_row = PairingCodeRow { - code: "111111".into(), - owner_user_id: OWNER_ID.into(), - platform_user_id: "u1".into(), - platform_type: "telegram".into(), - display_name: None, - requested_at: 1000, - expires_at: 2000, - status: "pending".into(), - }; + let expired_row = make_pairing_row("pair-stale", "111111", "u1", "telegram"); repo.create_pairing(OWNER_ID, &expired_row).await.unwrap(); let count = repo.cleanup_expired_pairings(OWNER_ID, now_ms()).await.unwrap(); assert_eq!(count, 1); - let row = repo.get_pairing_by_code(OWNER_ID, "111111").await.unwrap().unwrap(); + let row = repo.get_pairing(OWNER_ID, "pair-stale").await.unwrap().unwrap(); assert_eq!(row.status, "expired"); } @@ -320,7 +425,11 @@ async fn ec2_non_expired_unaffected() { let count = repo.cleanup_expired_pairings(OWNER_ID, now_ms()).await.unwrap(); assert_eq!(count, 0); - let row = repo.get_pairing_by_code(OWNER_ID, &code).await.unwrap().unwrap(); + let row = repo + .get_pending_pairing_by_code_hash(OWNER_ID, &hash(&code)) + .await + .unwrap() + .unwrap(); assert_eq!(row.status, "pending"); } @@ -335,16 +444,25 @@ async fn dc3_same_platform_user_unique() { .request_pairing(OWNER_ID, "tg_42", "telegram", Some("Alice")) .await .unwrap(); - svc.approve_pairing(OWNER_ID, &code1).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code1)) + .await + .unwrap(); // Second pairing for same user should fail on user creation (unique constraint) let code2 = svc .request_pairing(OWNER_ID, "tg_42", "telegram", Some("Alice")) .await .unwrap(); - let result = svc.approve_pairing(OWNER_ID, &code2).await; - // DB should reject duplicate (platform_user_id, platform_type) - assert!(result.is_err()); + let err = svc + .approve_pairing(OWNER_ID, PairingSelector::Code(&code2)) + .await + .unwrap_err(); + // The DB rejects a second ACTIVE authorization for the same + // (owner, connection, external user). + assert!( + matches!(err, ChannelError::Database(DbError::Conflict(_))), + "expected a conflict, got {err:?}" + ); } // ── WS-1: Pairing request broadcasts event ───────────────────────── @@ -364,6 +482,8 @@ async fn ws1_pairing_request_broadcasts_event() { assert_eq!(events[0].data["display_name"], "Alice"); assert!(events[0].data["code"].is_string()); assert!(events[0].data["expires_at"].is_number()); + // The event also carries the addressable request id. + assert!(events[0].data["id"].is_string()); } // ── WS-3: Approve broadcasts user-authorized event ───────────────── @@ -377,7 +497,9 @@ async fn ws3_approve_broadcasts_user_authorized() { .unwrap(); bc.take_events(); // clear request event - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); let events = bc.take_events(); assert_eq!(events.len(), 1); @@ -400,7 +522,9 @@ async fn is_user_authorized_false_before_approval() { async fn is_user_authorized_true_after_approval() { let (svc, _repo, _bc) = setup().await; let code = svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); assert!(svc.is_user_authorized(OWNER_ID, "tg_42", "telegram").await.unwrap()); } @@ -409,7 +533,9 @@ async fn is_user_authorized_true_after_approval() { async fn is_user_authorized_different_platform_false() { let (svc, _repo, _bc) = setup().await; let code = svc.request_pairing(OWNER_ID, "tg_42", "telegram", None).await.unwrap(); - svc.approve_pairing(OWNER_ID, &code).await.unwrap(); + svc.approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); // Same user ID but different platform assert!(!svc.is_user_authorized(OWNER_ID, "tg_42", "lark").await.unwrap()); diff --git a/crates/aionui-channel/tests/session_action_integration.rs b/crates/aionui-channel/tests/session_action_integration.rs index b84292cba..5395dc37f 100644 --- a/crates/aionui-channel/tests/session_action_integration.rs +++ b/crates/aionui-channel/tests/session_action_integration.rs @@ -7,13 +7,13 @@ use std::sync::{Arc, Mutex}; use aionui_api_types::WebSocketMessage; use aionui_common::{generate_id, now_ms}; -use aionui_db::models::AssistantUserRow; +use aionui_db::models::{ChannelConnectionRow, ChannelUserRow}; use aionui_db::{IChannelRepository, SqliteChannelRepository, init_database_memory}; use aionui_realtime::EventBroadcaster; use aionui_channel::action::{ActionExecutor, MessageResult}; use aionui_channel::channel_settings::ChannelSettingsService; -use aionui_channel::pairing::PairingService; +use aionui_channel::pairing::{PairingSelector, PairingService}; use aionui_channel::session::SessionManager; use aionui_channel::types::{ ActionBehavior, ActionCategory, ActionContext, MessageContentType, PluginType, UnifiedAction, @@ -22,6 +22,12 @@ use aionui_channel::types::{ // ── Test infrastructure ───────────────────────────────────────────── const OWNER_ID: &str = "system_default_user"; +/// Fixed key for the pairing code HMAC. +const TEST_KEY: [u8; 32] = [0x42u8; 32]; + +fn connection_id_for(plugin_key: &str) -> String { + format!("conn-{plugin_key}") +} struct MockBroadcaster { events: Mutex>>, @@ -51,9 +57,34 @@ async fn setup() -> ( let repo: Arc = Arc::new(SqliteChannelRepository::new(db.pool().clone())); let bc: Arc = Arc::new(MockBroadcaster::new()); + // Channel users and pairing requests hang off a connection. + for plugin_key in ["telegram", "lark"] { + repo.upsert_connection( + OWNER_ID, + &ChannelConnectionRow { + id: connection_id_for(plugin_key), + owner_user_id: OWNER_ID.to_owned(), + plugin_key: plugin_key.to_owned(), + name: format!("{plugin_key} bot"), + enabled: true, + config: "{}".into(), + status: None, + last_connected: None, + created_at: now_ms(), + updated_at: now_ms(), + }, + ) + .await + .unwrap(); + } + let session_mgr = SessionManager::new(repo.clone()); - let pairing = PairingService::new(repo.clone(), bc); - let pairing_arc = Arc::new(PairingService::new(repo.clone(), Arc::new(MockBroadcaster::new()))); + let pairing = PairingService::new(repo.clone(), bc, TEST_KEY); + let pairing_arc = Arc::new(PairingService::new( + repo.clone(), + Arc::new(MockBroadcaster::new()), + TEST_KEY, + )); let session_mgr_arc = Arc::new(SessionManager::new(repo.clone())); let pref_repo: Arc = Arc::new(aionui_db::SqliteClientPreferenceRepository::new(db.pool().clone())); @@ -65,18 +96,21 @@ async fn setup() -> ( (session_mgr, executor, pairing, repo) } -/// Create an assistant_users record (required for FK on sessions). +/// Create a channel_users record (required for FK on sessions). async fn create_user(repo: &Arc, platform_user_id: &str, platform_type: &str) -> String { let user_id = generate_id(); - let row = AssistantUserRow { + let row = ChannelUserRow { id: user_id.clone(), owner_user_id: OWNER_ID.to_owned(), + connection_id: connection_id_for(platform_type), platform_user_id: platform_user_id.to_owned(), + // Derived from the connection on read; ignored on write. platform_type: platform_type.to_owned(), display_name: Some("Test User".into()), + status: "active".into(), + revoked_at: None, authorized_at: now_ms(), last_active: None, - session_id: None, }; repo.create_user(OWNER_ID, &row).await.unwrap(); user_id @@ -85,6 +119,7 @@ async fn create_user(repo: &Arc, platform_user_id: &str, fn make_text_message(user_id: &str, chat_id: &str, text: &str) -> UnifiedIncomingMessage { UnifiedIncomingMessage { owner_user_id: None, + connection_id: None, id: format!("msg_{}", now_ms()), platform: PluginType::Telegram, chat_id: chat_id.into(), @@ -114,6 +149,7 @@ fn make_action_message( ) -> UnifiedIncomingMessage { UnifiedIncomingMessage { owner_user_id: None, + connection_id: None, id: format!("msg_{}", now_ms()), platform: PluginType::Telegram, chat_id: chat_id.into(), @@ -152,7 +188,10 @@ async fn authorize_user(pairing: &PairingService, platform_user_id: &str, platfo .request_pairing(OWNER_ID, platform_user_id, platform_type, Some("Test")) .await .unwrap(); - pairing.approve_pairing(OWNER_ID, &code).await.unwrap(); + pairing + .approve_pairing(OWNER_ID, PairingSelector::Code(&code)) + .await + .unwrap(); } // ── GS-1: No active sessions returns empty ───────────────────────── @@ -174,14 +213,8 @@ async fn gs2_multiple_sessions_returned() { let uid1 = create_user(&repo, "p1", "telegram").await; let uid2 = create_user(&repo, "p2", "telegram").await; - session_mgr - .get_or_create_session(OWNER_ID, &uid1, "c1", "gemini", None) - .await - .unwrap(); - session_mgr - .get_or_create_session(OWNER_ID, &uid2, "c2", "acp", None) - .await - .unwrap(); + session_mgr.get_or_create_session(OWNER_ID, &uid1, "c1").await.unwrap(); + session_mgr.get_or_create_session(OWNER_ID, &uid2, "c2").await.unwrap(); let sessions = session_mgr.get_active_sessions(OWNER_ID).await.unwrap(); assert_eq!(sessions.len(), 2); @@ -189,7 +222,9 @@ async fn gs2_multiple_sessions_returned() { for s in &sessions { assert!(!s.id.is_empty()); assert!(!s.user_id.is_empty()); - assert!(!s.agent_type.is_empty()); + // Identity is derived from the channel user both users were seeded on. + assert_eq!(s.owner_user_id, OWNER_ID); + assert_eq!(s.connection_id, connection_id_for("telegram")); assert!(s.chat_id.is_some()); assert!(s.created_at > 0); assert!(s.last_activity > 0); @@ -205,11 +240,11 @@ async fn pc1_same_user_different_chat() { let uid = create_user(&repo, "p1", "telegram").await; let s1 = session_mgr - .get_or_create_session(OWNER_ID, &uid, "chatA", "gemini", None) + .get_or_create_session(OWNER_ID, &uid, "chatA") .await .unwrap(); let s2 = session_mgr - .get_or_create_session(OWNER_ID, &uid, "chatB", "gemini", None) + .get_or_create_session(OWNER_ID, &uid, "chatB") .await .unwrap(); @@ -230,11 +265,11 @@ async fn pc2_different_users_same_chat() { let uid2 = create_user(&repo, "p2", "telegram").await; let s1 = session_mgr - .get_or_create_session(OWNER_ID, &uid1, "chatA", "gemini", None) + .get_or_create_session(OWNER_ID, &uid1, "chatA") .await .unwrap(); let s2 = session_mgr - .get_or_create_session(OWNER_ID, &uid2, "chatA", "gemini", None) + .get_or_create_session(OWNER_ID, &uid2, "chatA") .await .unwrap(); @@ -250,11 +285,11 @@ async fn pc3_same_user_same_chat_reuses() { let uid = create_user(&repo, "p1", "telegram").await; let s1 = session_mgr - .get_or_create_session(OWNER_ID, &uid, "chatA", "gemini", None) + .get_or_create_session(OWNER_ID, &uid, "chatA") .await .unwrap(); let s2 = session_mgr - .get_or_create_session(OWNER_ID, &uid, "chatA", "gemini", None) + .get_or_create_session(OWNER_ID, &uid, "chatA") .await .unwrap(); @@ -270,18 +305,9 @@ async fn ru3_revoke_clears_sessions() { let uid1 = create_user(&repo, "p1", "telegram").await; let uid2 = create_user(&repo, "p2", "telegram").await; - session_mgr - .get_or_create_session(OWNER_ID, &uid1, "c1", "gemini", None) - .await - .unwrap(); - session_mgr - .get_or_create_session(OWNER_ID, &uid1, "c2", "acp", None) - .await - .unwrap(); - session_mgr - .get_or_create_session(OWNER_ID, &uid2, "c1", "gemini", None) - .await - .unwrap(); + session_mgr.get_or_create_session(OWNER_ID, &uid1, "c1").await.unwrap(); + session_mgr.get_or_create_session(OWNER_ID, &uid1, "c2").await.unwrap(); + session_mgr.get_or_create_session(OWNER_ID, &uid2, "c1").await.unwrap(); // Cleanup user1 sessions session_mgr.cleanup_user_sessions(OWNER_ID, &uid1).await.unwrap(); diff --git a/crates/aionui-channel/tests/telegram_integration.rs b/crates/aionui-channel/tests/telegram_integration.rs index e2067982b..38d36f1c6 100644 --- a/crates/aionui-channel/tests/telegram_integration.rs +++ b/crates/aionui-channel/tests/telegram_integration.rs @@ -230,7 +230,7 @@ mod telegram_tests { #[tokio::test] async fn disable_without_db_row_returns_error() { let (manager, _repo, _bc) = setup().await; - // Plugin was never enabled (no DB row), so update_plugin_status fails + // Plugin was never enabled (no DB row), so update_connection_status fails let result = manager.disable_plugin(OWNER_USER_ID, "telegram").await; assert!(result.is_err()); } diff --git a/crates/aionui-db/migrations/031_channel_settings_refactor.sql b/crates/aionui-db/migrations/031_channel_settings_refactor.sql new file mode 100644 index 000000000..6a14cace0 --- /dev/null +++ b/crates/aionui-db/migrations/031_channel_settings_refactor.sql @@ -0,0 +1,465 @@ +-- Migration 030: channel connection entity + settings scope (4 segments). +-- +-- Segments 1-3: channel refactor A1-A3 (connection entity, users/pairing, +-- conversation bindings). Segment 4: settings-dedup B2 (preference scopes). +-- Segment 1 (channel refactor A1): +-- +-- Replaces `assistant_plugins` with `channel_connections`, decoupling the +-- connection instance from the platform type (07-16 §5.2 via the 2026-07-27 +-- split plan, task A1): +-- +-- * `id` becomes a generated, meaning-free connection id (the legacy rows +-- used the platform type itself as the id); +-- * the platform type moves to `plugin_key`; +-- * `PRIMARY KEY (owner_user_id, id)` stays the composite identity that +-- later segments' composite foreign keys will reference; +-- * phase 1 keeps exactly one instance per (owner, plugin_key) via a +-- unique index — multi-instance is a later product decision, at which +-- point that index is dropped. +-- +-- The legacy platform-type id remains recoverable as `plugin_key`, which is +-- how segment 2 backfills `channel_users.connection_id`. + +CREATE TABLE IF NOT EXISTS channel_connections ( + id TEXT NOT NULL, + owner_user_id TEXT NOT NULL DEFAULT 'system_default_user' REFERENCES users(id), + plugin_key TEXT NOT NULL, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + config TEXT NOT NULL, + status TEXT, + last_connected INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (owner_user_id, id) +); + +INSERT INTO channel_connections ( + id, owner_user_id, plugin_key, name, enabled, config, status, + last_connected, created_at, updated_at +) +SELECT + 'conn_' || lower(hex(randomblob(16))), + owner_user_id, + id, + name, + enabled, + config, + status, + last_connected, + created_at, + updated_at +FROM assistant_plugins; + +-- Migration-fatal integrity checks (user_scope_rebuild_checks pattern): +-- inserting `ok = 0` violates the CHECK and aborts the migration. +CREATE TEMPORARY TABLE channel_refactor_checks ( + ok INTEGER NOT NULL CHECK (ok = 1) +); + +-- Row conservation: every legacy plugin row became exactly one connection. +INSERT INTO channel_refactor_checks (ok) +SELECT CASE + WHEN (SELECT COUNT(*) FROM channel_connections) = (SELECT COUNT(*) FROM assistant_plugins) + THEN 1 + ELSE 0 +END; + +-- Legacy identity preserved: each (owner, legacy id) is now (owner, plugin_key). +INSERT INTO channel_refactor_checks (ok) +SELECT CASE + WHEN NOT EXISTS ( + SELECT 1 FROM assistant_plugins p + WHERE NOT EXISTS ( + SELECT 1 FROM channel_connections c + WHERE c.owner_user_id = p.owner_user_id AND c.plugin_key = p.id + ) + ) + THEN 1 + ELSE 0 +END; + +DROP TABLE assistant_plugins; +DROP TABLE channel_refactor_checks; + +-- Phase 1: one connection per (owner, plugin_key). Dropped when multi-instance +-- lands as a product feature. +CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_connections_single_instance + ON channel_connections(owner_user_id, plugin_key); +CREATE INDEX IF NOT EXISTS idx_channel_connections_owner_created_at + ON channel_connections(owner_user_id, created_at ASC); + +-- --------------------------------------------------------------------------- +-- Segment 2: channel_users + channel_pairing_requests (channel refactor A2). +-- +-- * `assistant_users` → `channel_users`: rows attach to their connection +-- (composite FK), the platform column disappears (derived from the +-- connection), `platform_user_id` becomes `external_user_id`, and +-- revocation becomes a soft delete (`status` = active|revoked with +-- `revoked_at`) so authorization history survives for audit. +-- The legacy `session_id` column is dropped (no stable semantics). +-- * `assistant_pairing_codes` → `channel_pairing_requests`: pairing rows +-- get a surrogate id, attach to their connection, and store only a +-- server-side HMAC of the code (`code_hash`) — the plaintext code never +-- touches the database. Legacy rows are NOT migrated: pairing codes are +-- 10-minute artifacts whose plaintext cannot (and must not) be hashed +-- retroactively, and historical rows carry no runtime state. +-- * `conversations` gains UNIQUE(user_id, id) — the shared foundation for +-- composite cross-account foreign keys (07-16 §5.4), used by segment 3. +-- +-- Legacy assistant_users rows whose platform has no connection row (the +-- plugin row was deleted after users were authorized) get a synthesized +-- disabled connection so no authorization silently disappears. +-- --------------------------------------------------------------------------- + +INSERT INTO channel_connections ( + id, owner_user_id, plugin_key, name, enabled, config, created_at, updated_at +) +SELECT + 'conn_' || lower(hex(randomblob(16))), + u.owner_user_id, + u.platform_type, + u.platform_type || ' (recovered)', + 0, + '', + strftime('%s', 'now') * 1000, + strftime('%s', 'now') * 1000 +FROM ( + SELECT DISTINCT owner_user_id, platform_type FROM assistant_users +) u +WHERE NOT EXISTS ( + SELECT 1 FROM channel_connections c + WHERE c.owner_user_id = u.owner_user_id AND c.plugin_key = u.platform_type +); + +CREATE TABLE channel_users ( + id TEXT PRIMARY KEY NOT NULL, + owner_user_id TEXT NOT NULL REFERENCES users(id), + connection_id TEXT NOT NULL, + external_user_id TEXT NOT NULL, + display_name TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), + revoked_at INTEGER, + authorized_at INTEGER NOT NULL, + last_active INTEGER, + FOREIGN KEY (owner_user_id, connection_id) REFERENCES channel_connections(owner_user_id, id), + UNIQUE (owner_user_id, connection_id, external_user_id) +); + +INSERT INTO channel_users ( + id, owner_user_id, connection_id, external_user_id, display_name, + status, revoked_at, authorized_at, last_active +) +SELECT + u.id, u.owner_user_id, c.id, u.platform_user_id, u.display_name, + 'active', NULL, u.authorized_at, u.last_active +FROM assistant_users u +JOIN channel_connections c + ON c.owner_user_id = u.owner_user_id AND c.plugin_key = u.platform_type; + +CREATE TEMPORARY TABLE channel_refactor_checks_2 ( + ok INTEGER NOT NULL CHECK (ok = 1) +); + +-- Row conservation: every authorized user survived the rebuild. +INSERT INTO channel_refactor_checks_2 (ok) +SELECT CASE + WHEN (SELECT COUNT(*) FROM channel_users) = (SELECT COUNT(*) FROM assistant_users) + THEN 1 + ELSE 0 +END; + +-- Rebuild assistant_sessions so its user FK follows the renamed parent. +-- Shape is unchanged here; segment 3 reshapes it into +-- channel_conversation_bindings. +CREATE TABLE assistant_sessions_new ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES channel_users(id) ON DELETE CASCADE, + agent_type TEXT NOT NULL, + conversation_id TEXT, + workspace TEXT, + chat_id TEXT, + created_at INTEGER NOT NULL, + last_activity INTEGER NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL +); + +INSERT INTO assistant_sessions_new SELECT * FROM assistant_sessions; + +INSERT INTO channel_refactor_checks_2 (ok) +SELECT CASE + WHEN (SELECT COUNT(*) FROM assistant_sessions_new) = (SELECT COUNT(*) FROM assistant_sessions) + THEN 1 + ELSE 0 +END; + +DROP TABLE assistant_sessions; +ALTER TABLE assistant_sessions_new RENAME TO assistant_sessions; +CREATE INDEX IF NOT EXISTS idx_assistant_sessions_user ON assistant_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_assistant_sessions_conversation ON assistant_sessions(conversation_id); + +DROP TABLE assistant_users; +DROP TABLE channel_refactor_checks_2; + +-- Pairing requests: hashed codes only; legacy 10-minute codes are dropped by +-- design (see segment header). +CREATE TABLE channel_pairing_requests ( + id TEXT PRIMARY KEY NOT NULL, + owner_user_id TEXT NOT NULL REFERENCES users(id), + connection_id TEXT NOT NULL, + external_user_id TEXT NOT NULL, + display_name TEXT, + code_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'approved', 'rejected', 'expired')), + requested_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + approved_channel_user_id TEXT REFERENCES channel_users(id), + FOREIGN KEY (owner_user_id, connection_id) REFERENCES channel_connections(owner_user_id, id) +); + +DROP TABLE assistant_pairing_codes; + +-- One pending request per (owner, connection, external user); one pending +-- request per (owner, code hash). +CREATE UNIQUE INDEX idx_channel_pairing_pending_user + ON channel_pairing_requests(owner_user_id, connection_id, external_user_id) + WHERE status = 'pending'; +CREATE UNIQUE INDEX idx_channel_pairing_pending_hash + ON channel_pairing_requests(owner_user_id, code_hash) + WHERE status = 'pending'; +CREATE INDEX idx_channel_pairing_owner_status_expiry + ON channel_pairing_requests(owner_user_id, status, expires_at); + +-- Shared foundation for composite cross-account FKs (07-16 §5.4). +CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_user_id_id + ON conversations(user_id, id); + +-- --------------------------------------------------------------------------- +-- Segment 3: assistant_sessions → channel_conversation_bindings (A3). +-- +-- * The binding attaches to its connection and channel user directly +-- (owner_user_id + connection_id + channel_user_id columns, composite FK +-- into channel_users), instead of deriving the owner through a join. +-- * `agent_type` and `workspace` are dropped: agent configuration is owned +-- by channel settings + the conversation snapshot, and the workspace +-- column never had a production reader. +-- * `chat_id` becomes `external_chat_id` (nullable is preserved: legacy +-- rows without a chat id keep their history; new sessions always carry +-- one). `last_activity` becomes `last_active_at`. +-- * Uniqueness: one binding per (owner, connection, channel user, external +-- chat) — "same user, different chat, different context" stays. +-- * Cross-account guard against conversations: enforced by triggers rather +-- than a composite FK — a composite FK's ON DELETE SET NULL would null +-- owner_user_id together with conversation_id, and NO ACTION would block +-- conversation deletion. The single-column conversation FK keeps its +-- ON DELETE SET NULL semantics; the triggers make a cross-account +-- binding unrepresentable (07-16 §5.4 intent). +-- --------------------------------------------------------------------------- + +-- Composite FK target for (owner, connection, channel user). +CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_users_owner_connection_id + ON channel_users(owner_user_id, connection_id, id); + +CREATE TABLE channel_conversation_bindings ( + id TEXT PRIMARY KEY NOT NULL, + owner_user_id TEXT NOT NULL REFERENCES users(id), + connection_id TEXT NOT NULL, + channel_user_id TEXT NOT NULL, + external_chat_id TEXT, + conversation_id TEXT REFERENCES conversations(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL, + last_active_at INTEGER NOT NULL, + FOREIGN KEY (owner_user_id, connection_id, channel_user_id) + REFERENCES channel_users(owner_user_id, connection_id, id) ON DELETE CASCADE, + UNIQUE (owner_user_id, connection_id, channel_user_id, external_chat_id) +); + +INSERT INTO channel_conversation_bindings ( + id, owner_user_id, connection_id, channel_user_id, external_chat_id, + conversation_id, created_at, last_active_at +) +SELECT + s.id, u.owner_user_id, u.connection_id, s.user_id, s.chat_id, + s.conversation_id, s.created_at, s.last_activity +FROM assistant_sessions s +JOIN channel_users u ON u.id = s.user_id; + +CREATE TEMPORARY TABLE channel_refactor_checks_3 ( + ok INTEGER NOT NULL CHECK (ok = 1) +); + +-- Row conservation: every session became exactly one binding. +INSERT INTO channel_refactor_checks_3 (ok) +SELECT CASE + WHEN (SELECT COUNT(*) FROM channel_conversation_bindings) = (SELECT COUNT(*) FROM assistant_sessions) + THEN 1 + ELSE 0 +END; + +-- No binding may reference a conversation owned by another Core user. +INSERT INTO channel_refactor_checks_3 (ok) +SELECT CASE + WHEN NOT EXISTS ( + SELECT 1 FROM channel_conversation_bindings b + JOIN conversations c ON c.id = b.conversation_id + WHERE c.user_id != b.owner_user_id + ) + THEN 1 + ELSE 0 +END; + +DROP TABLE assistant_sessions; +DROP TABLE channel_refactor_checks_3; + +CREATE INDEX IF NOT EXISTS idx_channel_bindings_owner_last_active + ON channel_conversation_bindings(owner_user_id, last_active_at DESC); +CREATE INDEX IF NOT EXISTS idx_channel_bindings_conversation + ON channel_conversation_bindings(conversation_id); + +-- Cross-account guard (see segment header): binding a conversation owned by +-- a different Core user is unrepresentable. +CREATE TRIGGER trg_channel_binding_conversation_owner_insert +BEFORE INSERT ON channel_conversation_bindings +FOR EACH ROW +WHEN NEW.conversation_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM conversations c + WHERE c.id = NEW.conversation_id AND c.user_id = NEW.owner_user_id +) +BEGIN + SELECT RAISE(ABORT, 'CROSS_ACCOUNT_REFERENCE: conversation belongs to another user'); +END; + +CREATE TRIGGER trg_channel_binding_conversation_owner_update +BEFORE UPDATE OF conversation_id, owner_user_id ON channel_conversation_bindings +FOR EACH ROW +WHEN NEW.conversation_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM conversations c + WHERE c.id = NEW.conversation_id AND c.user_id = NEW.owner_user_id +) +BEGIN + SELECT RAISE(ABORT, 'CROSS_ACCOUNT_REFERENCE: conversation belongs to another user'); +END; + +-- --------------------------------------------------------------------------- +-- Segment 4: device/account scope for client_preferences (settings-dedup B2; +-- disposition table in docs/superpowers/2026-07-27-settings-dedup-b1-inventory.md). +-- Folded into this migration so the combined channel/settings refactor ships +-- as a single migration file. +-- --------------------------------------------------------------------------- + +CREATE TABLE client_preferences_new ( + scope TEXT NOT NULL DEFAULT 'account' CHECK (scope IN ('device', 'account')), + user_id TEXT REFERENCES users(id), + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL, + CHECK ( + (scope = 'device' AND user_id IS NULL) + OR (scope = 'account' AND user_id IS NOT NULL) + ) +); + +INSERT INTO client_preferences_new (scope, user_id, key, value, updated_at) +SELECT 'account', user_id, key, value, updated_at +FROM client_preferences; + +CREATE TEMPORARY TABLE client_preference_scope_checks ( + ok INTEGER NOT NULL CHECK (ok = 1) +); + +INSERT INTO client_preference_scope_checks (ok) +SELECT CASE + WHEN (SELECT COUNT(*) FROM client_preferences_new) = (SELECT COUNT(*) FROM client_preferences) + THEN 1 + ELSE 0 +END; + +DROP TABLE client_preferences; +ALTER TABLE client_preferences_new RENAME TO client_preferences; + +-- Scope-aware uniqueness: one device value per key, one account value per +-- (user, key). +CREATE UNIQUE INDEX idx_client_preferences_device_key + ON client_preferences(key) WHERE scope = 'device'; +CREATE UNIQUE INDEX idx_client_preferences_account_key + ON client_preferences(user_id, key) WHERE scope = 'account'; + +-- Promote confirmed device-level keys: latest write wins across users. +INSERT INTO client_preferences (scope, user_id, key, value, updated_at) +SELECT 'device', NULL, key, value, updated_at +FROM ( + SELECT + key, value, updated_at, + ROW_NUMBER() OVER (PARTITION BY key ORDER BY updated_at DESC, user_id ASC) AS rn + FROM client_preferences + WHERE scope = 'account' + AND ( + key IN ('system.closeToTray', 'keepAwake', 'autoPreviewOfficeFiles') + OR key LIKE 'pet.%' + ) +) +WHERE rn = 1; + +DELETE FROM client_preferences +WHERE scope = 'account' + AND ( + key IN ('system.closeToTray', 'keepAwake', 'autoPreviewOfficeFiles') + OR key LIKE 'pet.%' + ); + +-- Materialize the system_settings switches as account-scope keys. INSERT OR +-- IGNORE: an existing preference row (e.g. written post-B1) is the newer +-- truth and must not be clobbered. +INSERT OR IGNORE INTO client_preferences (scope, user_id, key, value, updated_at) +SELECT 'account', s.user_id, 'system.notificationEnabled', + CASE WHEN s.notification_enabled THEN 'true' ELSE 'false' END, s.updated_at +FROM system_settings s; + +INSERT OR IGNORE INTO client_preferences (scope, user_id, key, value, updated_at) +SELECT 'account', s.user_id, 'cron.notificationEnabled', + CASE WHEN s.cron_notification_enabled THEN 'true' ELSE 'false' END, s.updated_at +FROM system_settings s; + +INSERT OR IGNORE INTO client_preferences (scope, user_id, key, value, updated_at) +SELECT 'account', s.user_id, 'system.commandQueueEnabled', + CASE WHEN s.command_queue_enabled THEN 'true' ELSE 'false' END, s.updated_at +FROM system_settings s; + +INSERT OR IGNORE INTO client_preferences (scope, user_id, key, value, updated_at) +SELECT 'account', s.user_id, 'system.saveUploadToWorkspace', + CASE WHEN s.save_upload_to_workspace THEN 'true' ELSE 'false' END, s.updated_at +FROM system_settings s; + +-- Migration validation: every migrated switch column is readable back as a +-- preference for every settings row. +INSERT INTO client_preference_scope_checks (ok) +SELECT CASE + WHEN NOT EXISTS ( + SELECT 1 FROM system_settings s + WHERE NOT EXISTS ( + SELECT 1 FROM client_preferences p + WHERE p.scope = 'account' AND p.user_id = s.user_id + AND p.key = 'system.notificationEnabled' + ) + OR NOT EXISTS ( + SELECT 1 FROM client_preferences p + WHERE p.scope = 'account' AND p.user_id = s.user_id + AND p.key = 'cron.notificationEnabled' + ) + OR NOT EXISTS ( + SELECT 1 FROM client_preferences p + WHERE p.scope = 'account' AND p.user_id = s.user_id + AND p.key = 'system.commandQueueEnabled' + ) + OR NOT EXISTS ( + SELECT 1 FROM client_preferences p + WHERE p.scope = 'account' AND p.user_id = s.user_id + AND p.key = 'system.saveUploadToWorkspace' + ) + ) + THEN 1 + ELSE 0 +END; + +DROP TABLE client_preference_scope_checks; diff --git a/crates/aionui-db/src/lib.rs b/crates/aionui-db/src/lib.rs index f56665261..3a2d1b4f9 100644 --- a/crates/aionui-db/src/lib.rs +++ b/crates/aionui-db/src/lib.rs @@ -31,7 +31,7 @@ pub use models::{ UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, UpsertOverrideParams, UserStatus, UserType, }; -pub use repository::channel::UpdatePluginStatusParams; +pub use repository::channel::UpdateConnectionStatusParams; pub use repository::conversation::{ ConversationFilters, ConversationRowUpdate, MessagePageCursor, MessagePageDirection, MessagePageParams, MessagePageResult, MessageRowUpdate, MessageSearchRow, StaleRuntimeMessageRow, diff --git a/crates/aionui-db/src/models/channel.rs b/crates/aionui-db/src/models/channel.rs index cbadca203..c7946bff4 100644 --- a/crates/aionui-db/src/models/channel.rs +++ b/crates/aionui-db/src/models/channel.rs @@ -1,17 +1,19 @@ use aionui_common::TimestampMs; use serde::{Deserialize, Serialize}; -/// Row mapping for the `assistant_plugins` table. +/// Row mapping for the `channel_connections` table. /// -/// Stores channel plugin configurations. The `config` column holds an -/// encrypted JSON blob containing credentials and options. +/// One connection instance of a channel plugin. `id` is a generated, +/// meaning-free connection id; the platform lives in `plugin_key`. The +/// `config` column holds an encrypted JSON blob containing credentials and +/// options. Phase 1 keeps one connection per (owner, plugin_key). #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct ChannelPluginRow { +pub struct ChannelConnectionRow { pub id: String, pub owner_user_id: String, - /// Platform type (telegram, lark, dingtalk, weixin, slack, discord). - #[sqlx(rename = "type")] - pub r#type: String, + /// Platform key (telegram, lark, dingtalk, weixin, slack, discord, or an + /// extension-contributed plugin id). + pub plugin_key: String, pub name: String, pub enabled: bool, /// JSON blob: `{ credentials, config }`. Stored encrypted at rest. @@ -22,51 +24,83 @@ pub struct ChannelPluginRow { pub updated_at: TimestampMs, } -/// Row mapping for the `assistant_users` table. +/// Row mapping for the `channel_users` table (+ derived platform). /// -/// Represents an IM user authorized to chat with the assistant. -/// UNIQUE constraint on (owner_user_id, platform_user_id, platform_type). +/// Represents an IM user authorized to chat with the assistant, attached to +/// the connection that authorized them. Revocation is a soft delete +/// (`status` = active|revoked) so authorization history survives for audit. +/// +/// Field-name bridge (channel refactor A2): the DB column is +/// `external_user_id`, surfaced here as `platform_user_id` to keep the wide +/// caller surface stable; `platform_type` is NOT a column — reads derive it +/// from the connection (`channel_connections.plugin_key`), and writes ignore +/// it in favor of `connection_id`. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct AssistantUserRow { +pub struct ChannelUserRow { pub id: String, pub owner_user_id: String, + pub connection_id: String, + #[sqlx(rename = "external_user_id")] pub platform_user_id: String, + /// Derived from the joined connection; not stored on this table. pub platform_type: String, pub display_name: Option, + pub status: String, + pub revoked_at: Option, pub authorized_at: TimestampMs, pub last_active: Option, - pub session_id: Option, } -/// Row mapping for the `assistant_sessions` table. +/// Row mapping for the `channel_conversation_bindings` table. +/// +/// Per-chat binding linking an authorized channel user to a conversation. +/// FK: (owner_user_id, connection_id, channel_user_id) → channel_users +/// ON DELETE CASCADE; conversation_id → conversations(id) ON DELETE SET NULL, +/// with triggers making cross-account conversation bindings unrepresentable. /// -/// Per-chat session linking an authorized user to a conversation. -/// FK: user_id → assistant_users(id) ON DELETE CASCADE. -/// FK: conversation_id → conversations(id) ON DELETE SET NULL. +/// Field-name bridge (channel refactor A3): `user_id` maps the +/// `channel_user_id` column, `chat_id` maps `external_chat_id`, and +/// `last_activity` maps `last_active_at` — names kept to limit caller churn. +/// The legacy `agent_type`/`workspace` columns are gone: agent configuration +/// is owned by channel settings + the conversation snapshot. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct AssistantSessionRow { +pub struct ChannelConversationBindingRow { pub id: String, + pub owner_user_id: String, + pub connection_id: String, + #[sqlx(rename = "channel_user_id")] pub user_id: String, - pub agent_type: String, - pub conversation_id: Option, - pub workspace: Option, + #[sqlx(rename = "external_chat_id")] pub chat_id: Option, + pub conversation_id: Option, pub created_at: TimestampMs, + #[sqlx(rename = "last_active_at")] pub last_activity: TimestampMs, } -/// Row mapping for the `assistant_pairing_codes` table. +/// Row mapping for the `channel_pairing_requests` table (+ derived platform). /// /// 6-digit pairing code with 10-minute expiry. Status transitions: -/// pending → approved | rejected | expired. +/// pending → approved | rejected | expired. Only a server-side HMAC of the +/// code is stored (`code_hash`); the plaintext code exists solely in the +/// transient pairing flow (IM message + WebSocket event). +/// +/// Same field-name bridge as [`ChannelUserRow`]: `platform_user_id` maps the +/// `external_user_id` column and `platform_type` is derived from the joined +/// connection. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct PairingCodeRow { - pub code: String, +pub struct ChannelPairingRequestRow { + pub id: String, pub owner_user_id: String, + pub connection_id: String, + #[sqlx(rename = "external_user_id")] pub platform_user_id: String, + /// Derived from the joined connection; not stored on this table. pub platform_type: String, pub display_name: Option, + pub code_hash: String, + pub status: String, pub requested_at: TimestampMs, pub expires_at: TimestampMs, - pub status: String, + pub approved_channel_user_id: Option, } diff --git a/crates/aionui-db/src/models/client_preference.rs b/crates/aionui-db/src/models/client_preference.rs index c75858e2b..571ef8951 100644 --- a/crates/aionui-db/src/models/client_preference.rs +++ b/crates/aionui-db/src/models/client_preference.rs @@ -4,9 +4,17 @@ use serde::{Deserialize, Serialize}; /// Row mapping for the `client_preferences` table. /// /// Generic key-value store. Values are stored as JSON-serialized TEXT. +/// +/// Rows carry a scope (migration 031): `'account'` rows are per-user and +/// always have a `user_id`; `'device'` rows are machine-level (one value per +/// key for the whole machine) and always have `user_id = NULL`. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct ClientPreference { - pub user_id: String, + /// `'account'` or `'device'` — enforced by a CHECK constraint together + /// with the nullability of `user_id`. + pub scope: String, + /// Owning user for account-scope rows; `None` for device-scope rows. + pub user_id: Option, pub key: String, pub value: String, pub updated_at: TimestampMs, diff --git a/crates/aionui-db/src/models/mod.rs b/crates/aionui-db/src/models/mod.rs index 2c5ac5953..0bbec0841 100644 --- a/crates/aionui-db/src/models/mod.rs +++ b/crates/aionui-db/src/models/mod.rs @@ -26,7 +26,7 @@ pub use assistant::{ CreateAssistantParams, UpdateAssistantParams, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, UpsertAssistantPreferenceParams, UpsertOverrideParams, }; -pub use channel::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow}; +pub use channel::{ChannelConnectionRow, ChannelConversationBindingRow, ChannelPairingRequestRow, ChannelUserRow}; pub use client_preference::ClientPreference; pub use conversation::{ConversationAssistantSnapshotRow, ConversationRow, UpsertConversationAssistantSnapshotParams}; pub use conversation_artifact::ConversationArtifactRow; diff --git a/crates/aionui-db/src/repository/channel.rs b/crates/aionui-db/src/repository/channel.rs index b53b7d2de..196bdfca4 100644 --- a/crates/aionui-db/src/repository/channel.rs +++ b/crates/aionui-db/src/repository/channel.rs @@ -1,53 +1,71 @@ use aionui_common::TimestampMs; use crate::error::DbError; -use crate::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow}; +use crate::models::{ChannelConnectionRow, ChannelConversationBindingRow, ChannelPairingRequestRow, ChannelUserRow}; /// Data access abstraction for channel integration tables. /// -/// Covers four tables: `assistant_plugins`, `assistant_users`, -/// `assistant_sessions`, and `assistant_pairing_codes`. +/// Covers four tables: `channel_connections`, `channel_users`, +/// `channel_conversation_bindings`, and `channel_pairing_requests`. /// /// Object-safe via `async_trait` to support `Arc`. #[async_trait::async_trait] pub trait IChannelRepository: Send + Sync { - // ── Plugin CRUD ────────────────────────────────────────────────── + // ── Connection CRUD ────────────────────────────────────────────── - /// Returns all registered plugins for an owner. - async fn get_all_plugins(&self, owner_user_id: &str) -> Result, DbError>; + /// Returns all channel connections for an owner. + async fn get_all_connections(&self, owner_user_id: &str) -> Result, DbError>; - /// Returns a single plugin by id, or `None` if not found. - async fn get_plugin(&self, owner_user_id: &str, id: &str) -> Result, DbError>; + /// Returns a single connection by connection id, or `None` if not found. + async fn get_connection(&self, owner_user_id: &str, id: &str) -> Result, DbError>; - /// Inserts a new plugin or updates an existing one (by id). - async fn upsert_plugin(&self, owner_user_id: &str, row: &ChannelPluginRow) -> Result<(), DbError>; + /// Returns the owner's connection for a plugin key, or `None`. + /// + /// Phase 1 guarantees at most one connection per (owner, plugin_key) + /// (`idx_channel_connections_single_instance`), which is what makes this + /// lookup well-defined; it is the bridge for callers that still address + /// channels by platform. + async fn get_connection_by_plugin_key( + &self, + owner_user_id: &str, + plugin_key: &str, + ) -> Result, DbError>; + + /// Inserts a new connection or updates an existing one (by connection id). + async fn upsert_connection(&self, owner_user_id: &str, row: &ChannelConnectionRow) -> Result<(), DbError>; - /// Updates only the `status` and `last_connected` of a plugin. - async fn update_plugin_status( + /// Updates only the `status` / `last_connected` / `enabled` of a connection. + async fn update_connection_status( &self, owner_user_id: &str, id: &str, - params: &UpdatePluginStatusParams, + params: &UpdateConnectionStatusParams, ) -> Result<(), DbError>; - /// Deletes a plugin by id. Returns `DbError::NotFound` if absent. - async fn delete_plugin(&self, owner_user_id: &str, id: &str) -> Result<(), DbError>; + /// Deletes a connection by connection id. Returns `DbError::NotFound` if absent. + async fn delete_connection(&self, owner_user_id: &str, id: &str) -> Result<(), DbError>; - // ── User CRUD ──────────────────────────────────────────────────── + // ── Channel user CRUD ──────────────────────────────────────────── - /// Returns all authorized users for an owner. - async fn get_all_users(&self, owner_user_id: &str) -> Result, DbError>; + /// Returns all active (non-revoked) authorized users for an owner. + async fn get_all_users(&self, owner_user_id: &str) -> Result, DbError>; - /// Finds a user by platform identity. Returns `None` if not found. + /// Finds an active user by platform identity (bridged through the + /// connection's plugin_key). Returns `None` if not found or revoked. async fn get_user_by_platform( &self, owner_user_id: &str, platform_user_id: &str, platform_type: &str, - ) -> Result, DbError>; + ) -> Result, DbError>; - /// Creates a new authorized user record. - async fn create_user(&self, owner_user_id: &str, row: &AssistantUserRow) -> Result<(), DbError>; + /// Creates a new authorized user record, or reactivates a previously + /// revoked row for the same (owner, connection, external user). + /// An already-active row is a `DbError::Conflict`. + /// + /// `row.connection_id` must reference the owner's connection; + /// `row.platform_type` is derived state and ignored on write. + async fn create_user(&self, owner_user_id: &str, row: &ChannelUserRow) -> Result<(), DbError>; /// Updates `last_active` timestamp for a user. async fn update_user_last_active( @@ -57,17 +75,22 @@ pub trait IChannelRepository: Send + Sync { last_active: TimestampMs, ) -> Result<(), DbError>; - /// Deletes a user by id. Returns `DbError::NotFound` if absent. - /// Associated sessions are cascade-deleted by the database. - async fn delete_user(&self, owner_user_id: &str, id: &str) -> Result<(), DbError>; + /// Revokes a user's authorization (soft delete: `status = 'revoked'`, + /// audit row retained) and deletes their sessions. Returns + /// `DbError::NotFound` if no active row exists. + async fn revoke_user(&self, owner_user_id: &str, id: &str) -> Result<(), DbError>; // ── Session CRUD ───────────────────────────────────────────────── /// Returns all sessions for an owner. - async fn get_all_sessions(&self, owner_user_id: &str) -> Result, DbError>; + async fn get_all_sessions(&self, owner_user_id: &str) -> Result, DbError>; /// Returns a single session by id. - async fn get_session(&self, owner_user_id: &str, id: &str) -> Result, DbError>; + async fn get_session( + &self, + owner_user_id: &str, + id: &str, + ) -> Result, DbError>; /// Finds an existing session by user + chat, or creates a new one. /// If found, updates `last_activity` and returns the existing row. @@ -77,8 +100,8 @@ pub trait IChannelRepository: Send + Sync { owner_user_id: &str, channel_user_id: &str, chat_id: &str, - new_row: &AssistantSessionRow, - ) -> Result; + new_row: &ChannelConversationBindingRow, + ) -> Result; /// Updates `last_activity` timestamp for a session. async fn update_session_activity( @@ -96,9 +119,6 @@ pub trait IChannelRepository: Send + Sync { conversation_id: &str, ) -> Result<(), DbError>; - /// Updates the `agent_type` of a session. - async fn update_session_agent_type(&self, owner_user_id: &str, id: &str, agent_type: &str) -> Result<(), DbError>; - /// Deletes all sessions belonging to a user. async fn delete_sessions_by_user(&self, owner_user_id: &str, channel_user_id: &str) -> Result<(), DbError>; @@ -110,29 +130,53 @@ pub trait IChannelRepository: Send + Sync { chat_id: &str, ) -> Result<(), DbError>; - // ── Pairing Codes ──────────────────────────────────────────────── + // ── Pairing requests ───────────────────────────────────────────── - /// Creates a new pairing code record. - async fn create_pairing(&self, owner_user_id: &str, row: &PairingCodeRow) -> Result<(), DbError>; + /// Creates a new pairing request. `row.code_hash` carries the HMAC of + /// the code; the plaintext code is never persisted. + async fn create_pairing(&self, owner_user_id: &str, row: &ChannelPairingRequestRow) -> Result<(), DbError>; - /// Returns all pairing codes with status = 'pending'. - async fn get_pending_pairings(&self, owner_user_id: &str) -> Result, DbError>; + /// Returns all pairing requests with status = 'pending'. + async fn get_pending_pairings(&self, owner_user_id: &str) -> Result, DbError>; - /// Retrieves a single pairing code, or `None` if not found. - async fn get_pairing_by_code(&self, owner_user_id: &str, code: &str) -> Result, DbError>; + /// Retrieves a pairing request by surrogate id, or `None` if not found. + async fn get_pairing(&self, owner_user_id: &str, id: &str) -> Result, DbError>; - /// Updates the status of a pairing code. - /// Returns `DbError::NotFound` if the code doesn't exist. - async fn update_pairing_status(&self, owner_user_id: &str, code: &str, status: &str) -> Result<(), DbError>; + /// Retrieves the pending pairing request matching a code hash, or `None`. + async fn get_pending_pairing_by_code_hash( + &self, + owner_user_id: &str, + code_hash: &str, + ) -> Result, DbError>; + + /// Updates the status of a pairing request (by surrogate id), optionally + /// recording the channel user created by an approval. + /// Returns `DbError::NotFound` if the request doesn't exist. + async fn update_pairing_status( + &self, + owner_user_id: &str, + id: &str, + status: &str, + approved_channel_user_id: Option<&str>, + ) -> Result<(), DbError>; + + /// Expires any pending requests for one (connection, external user). + /// Used before issuing a fresh code for the same user. + async fn expire_pending_pairings_for_user( + &self, + owner_user_id: &str, + connection_id: &str, + external_user_id: &str, + ) -> Result; - /// Marks all expired-but-still-pending pairing codes as 'expired'. + /// Marks all expired-but-still-pending pairing requests as 'expired'. /// `now` is the current timestamp in milliseconds. async fn cleanup_expired_pairings(&self, owner_user_id: &str, now: TimestampMs) -> Result; } -/// Parameters for updating plugin runtime status. +/// Parameters for updating connection runtime status. #[derive(Debug, Clone, Default)] -pub struct UpdatePluginStatusParams { +pub struct UpdateConnectionStatusParams { pub status: Option, pub last_connected: Option, pub enabled: Option, diff --git a/crates/aionui-db/src/repository/client_preference.rs b/crates/aionui-db/src/repository/client_preference.rs index 06d39da05..afa96f1b1 100644 --- a/crates/aionui-db/src/repository/client_preference.rs +++ b/crates/aionui-db/src/repository/client_preference.rs @@ -3,19 +3,42 @@ use crate::models::ClientPreference; /// Client preference data access abstraction. /// -/// Provides CRUD operations on the generic key-value `client_preferences` table. +/// Provides CRUD operations on the generic key-value `client_preferences` +/// table. The table has two scopes (migration 031): +/// +/// * **account** — per-user rows, addressed by `(user_id, key)`. The +/// `*_by_keys` / `get_all` / `upsert_batch` / `delete_keys` methods operate +/// exclusively on this scope. +/// * **device** — machine-level rows with `user_id = NULL`, addressed by `key` +/// alone. The `*_device*` methods operate exclusively on this scope. +/// +/// Routing a key to the right scope is a service-layer decision; this trait +/// never infers a scope from the key name. #[async_trait::async_trait] pub trait IClientPreferenceRepository: Send + Sync { - /// Returns all client preferences. + /// Returns all account-scope preferences for the given user. async fn get_all(&self, user_id: &str) -> Result, DbError>; - /// Returns preferences for the given keys only. + /// Returns the user's account-scope preferences for the given keys only. /// Keys that don't exist are simply omitted from the result. async fn get_by_keys(&self, user_id: &str, keys: &[&str]) -> Result, DbError>; - /// Inserts or updates a batch of key-value pairs. + /// Inserts or updates a batch of account-scope key-value pairs. async fn upsert_batch(&self, user_id: &str, entries: &[(&str, &str)]) -> Result<(), DbError>; - /// Deletes the given keys. + /// Deletes the given account-scope keys for the user. async fn delete_keys(&self, user_id: &str, keys: &[&str]) -> Result<(), DbError>; + + /// Returns all device-scope (machine-level) preferences. + async fn get_all_device(&self) -> Result, DbError>; + + /// Returns the device-scope preferences for the given keys only. + /// Keys that don't exist are simply omitted from the result. + async fn get_device_by_keys(&self, keys: &[&str]) -> Result, DbError>; + + /// Inserts or updates a batch of device-scope key-value pairs. + async fn upsert_device_batch(&self, entries: &[(&str, &str)]) -> Result<(), DbError>; + + /// Deletes the given device-scope keys. + async fn delete_device_keys(&self, keys: &[&str]) -> Result<(), DbError>; } diff --git a/crates/aionui-db/src/repository/sqlite_channel.rs b/crates/aionui-db/src/repository/sqlite_channel.rs index ad6fd9c72..9b4bf95cc 100644 --- a/crates/aionui-db/src/repository/sqlite_channel.rs +++ b/crates/aionui-db/src/repository/sqlite_channel.rs @@ -1,8 +1,8 @@ use sqlx::SqlitePool; use crate::error::DbError; -use crate::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow}; -use crate::repository::channel::{IChannelRepository, UpdatePluginStatusParams}; +use crate::models::{ChannelConnectionRow, ChannelConversationBindingRow, ChannelPairingRequestRow, ChannelUserRow}; +use crate::repository::channel::{IChannelRepository, UpdateConnectionStatusParams}; /// SQLite-backed implementation of [`IChannelRepository`]. #[derive(Clone, Debug)] @@ -18,11 +18,11 @@ impl SqliteChannelRepository { #[async_trait::async_trait] impl IChannelRepository for SqliteChannelRepository { - // ── Plugin CRUD ────────────────────────────────────────────────── + // ── Connection CRUD ────────────────────────────────────────────── - async fn get_all_plugins(&self, owner_user_id: &str) -> Result, DbError> { - let rows = sqlx::query_as::<_, ChannelPluginRow>( - "SELECT * FROM assistant_plugins WHERE owner_user_id = ? ORDER BY created_at ASC", + async fn get_all_connections(&self, owner_user_id: &str) -> Result, DbError> { + let rows = sqlx::query_as::<_, ChannelConnectionRow>( + "SELECT * FROM channel_connections WHERE owner_user_id = ? ORDER BY created_at ASC", ) .bind(owner_user_id) .fetch_all(&self.pool) @@ -30,23 +30,39 @@ impl IChannelRepository for SqliteChannelRepository { Ok(rows) } - async fn get_plugin(&self, owner_user_id: &str, id: &str) -> Result, DbError> { - let row = - sqlx::query_as::<_, ChannelPluginRow>("SELECT * FROM assistant_plugins WHERE owner_user_id = ? AND id = ?") - .bind(owner_user_id) - .bind(id) - .fetch_optional(&self.pool) - .await?; + async fn get_connection(&self, owner_user_id: &str, id: &str) -> Result, DbError> { + let row = sqlx::query_as::<_, ChannelConnectionRow>( + "SELECT * FROM channel_connections WHERE owner_user_id = ? AND id = ?", + ) + .bind(owner_user_id) + .bind(id) + .fetch_optional(&self.pool) + .await?; Ok(row) } - async fn upsert_plugin(&self, owner_user_id: &str, row: &ChannelPluginRow) -> Result<(), DbError> { + async fn get_connection_by_plugin_key( + &self, + owner_user_id: &str, + plugin_key: &str, + ) -> Result, DbError> { + let row = sqlx::query_as::<_, ChannelConnectionRow>( + "SELECT * FROM channel_connections WHERE owner_user_id = ? AND plugin_key = ?", + ) + .bind(owner_user_id) + .bind(plugin_key) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } + + async fn upsert_connection(&self, owner_user_id: &str, row: &ChannelConnectionRow) -> Result<(), DbError> { sqlx::query( - "INSERT INTO assistant_plugins \ - (id, owner_user_id, type, name, enabled, config, status, last_connected, created_at, updated_at) \ + "INSERT INTO channel_connections \ + (id, owner_user_id, plugin_key, name, enabled, config, status, last_connected, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(owner_user_id, id) DO UPDATE SET \ - type = excluded.type, \ + plugin_key = excluded.plugin_key, \ name = excluded.name, \ enabled = excluded.enabled, \ config = excluded.config, \ @@ -56,7 +72,7 @@ impl IChannelRepository for SqliteChannelRepository { ) .bind(&row.id) .bind(owner_user_id) - .bind(&row.r#type) + .bind(&row.plugin_key) .bind(&row.name) .bind(row.enabled) .bind(&row.config) @@ -69,11 +85,11 @@ impl IChannelRepository for SqliteChannelRepository { Ok(()) } - async fn update_plugin_status( + async fn update_connection_status( &self, owner_user_id: &str, id: &str, - params: &UpdatePluginStatusParams, + params: &UpdateConnectionStatusParams, ) -> Result<(), DbError> { let mut set_clauses = Vec::new(); if params.status.is_some() { @@ -92,7 +108,7 @@ impl IChannelRepository for SqliteChannelRepository { set_clauses.push("updated_at = ?"); let sql = format!( - "UPDATE assistant_plugins SET {} WHERE owner_user_id = ? AND id = ?", + "UPDATE channel_connections SET {} WHERE owner_user_id = ? AND id = ?", set_clauses.join(", ") ); @@ -114,28 +130,32 @@ impl IChannelRepository for SqliteChannelRepository { let result = query.execute(&self.pool).await?; if result.rows_affected() == 0 { - return Err(DbError::NotFound(format!("Plugin '{id}' not found"))); + return Err(DbError::NotFound(format!("Connection '{id}' not found"))); } Ok(()) } - async fn delete_plugin(&self, owner_user_id: &str, id: &str) -> Result<(), DbError> { - let result = sqlx::query("DELETE FROM assistant_plugins WHERE owner_user_id = ? AND id = ?") + async fn delete_connection(&self, owner_user_id: &str, id: &str) -> Result<(), DbError> { + let result = sqlx::query("DELETE FROM channel_connections WHERE owner_user_id = ? AND id = ?") .bind(owner_user_id) .bind(id) .execute(&self.pool) .await?; if result.rows_affected() == 0 { - return Err(DbError::NotFound(format!("Plugin '{id}' not found"))); + return Err(DbError::NotFound(format!("Connection '{id}' not found"))); } Ok(()) } - // ── User CRUD ──────────────────────────────────────────────────── + // ── Channel user CRUD ──────────────────────────────────────────── - async fn get_all_users(&self, owner_user_id: &str) -> Result, DbError> { - let rows = sqlx::query_as::<_, AssistantUserRow>( - "SELECT * FROM assistant_users WHERE owner_user_id = ? ORDER BY authorized_at DESC", + async fn get_all_users(&self, owner_user_id: &str) -> Result, DbError> { + let rows = sqlx::query_as::<_, ChannelUserRow>( + "SELECT u.*, c.plugin_key AS platform_type \ + FROM channel_users u \ + JOIN channel_connections c ON c.owner_user_id = u.owner_user_id AND c.id = u.connection_id \ + WHERE u.owner_user_id = ? AND u.status = 'active' \ + ORDER BY u.authorized_at DESC", ) .bind(owner_user_id) .fetch_all(&self.pool) @@ -148,10 +168,13 @@ impl IChannelRepository for SqliteChannelRepository { owner_user_id: &str, platform_user_id: &str, platform_type: &str, - ) -> Result, DbError> { - let row = sqlx::query_as::<_, AssistantUserRow>( - "SELECT * FROM assistant_users \ - WHERE owner_user_id = ? AND platform_user_id = ? AND platform_type = ?", + ) -> Result, DbError> { + let row = sqlx::query_as::<_, ChannelUserRow>( + "SELECT u.*, c.plugin_key AS platform_type \ + FROM channel_users u \ + JOIN channel_connections c ON c.owner_user_id = u.owner_user_id AND c.id = u.connection_id \ + WHERE u.owner_user_id = ? AND u.external_user_id = ? AND c.plugin_key = ? \ + AND u.status = 'active'", ) .bind(owner_user_id) .bind(platform_user_id) @@ -161,33 +184,71 @@ impl IChannelRepository for SqliteChannelRepository { Ok(row) } - async fn create_user(&self, owner_user_id: &str, row: &AssistantUserRow) -> Result<(), DbError> { - sqlx::query( - "INSERT INTO assistant_users \ - (id, owner_user_id, platform_user_id, platform_type, display_name, \ - authorized_at, last_active, session_id) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + async fn create_user(&self, owner_user_id: &str, row: &ChannelUserRow) -> Result<(), DbError> { + // Reactivate a previously revoked row for the same identity; + // an already-active row is a conflict. + let mut tx = self.pool.begin().await?; + let existing: Option<(String, String)> = sqlx::query_as( + "SELECT id, status FROM channel_users \ + WHERE owner_user_id = ? AND connection_id = ? AND external_user_id = ?", ) - .bind(&row.id) .bind(owner_user_id) + .bind(&row.connection_id) .bind(&row.platform_user_id) - .bind(&row.platform_type) - .bind(&row.display_name) - .bind(row.authorized_at) - .bind(row.last_active) - .bind(&row.session_id) - .execute(&self.pool) - .await - .map_err(|e| { - if is_unique_violation(&e) { - DbError::Conflict(format!( - "User '{}' on platform '{}' already exists", - row.platform_user_id, row.platform_type - )) - } else { - DbError::Query(e) + .fetch_optional(&mut *tx) + .await?; + + match existing { + Some((_, status)) if status == "active" => { + return Err(DbError::Conflict(format!( + "User '{}' on connection '{}' already exists", + row.platform_user_id, row.connection_id + ))); } - })?; + Some((existing_id, _)) => { + sqlx::query( + "UPDATE channel_users \ + SET status = 'active', revoked_at = NULL, display_name = ?, \ + authorized_at = ?, last_active = ? \ + WHERE owner_user_id = ? AND id = ?", + ) + .bind(&row.display_name) + .bind(row.authorized_at) + .bind(row.last_active) + .bind(owner_user_id) + .bind(&existing_id) + .execute(&mut *tx) + .await?; + } + None => { + sqlx::query( + "INSERT INTO channel_users \ + (id, owner_user_id, connection_id, external_user_id, display_name, \ + status, revoked_at, authorized_at, last_active) \ + VALUES (?, ?, ?, ?, ?, 'active', NULL, ?, ?)", + ) + .bind(&row.id) + .bind(owner_user_id) + .bind(&row.connection_id) + .bind(&row.platform_user_id) + .bind(&row.display_name) + .bind(row.authorized_at) + .bind(row.last_active) + .execute(&mut *tx) + .await + .map_err(|e| { + if is_unique_violation(&e) { + DbError::Conflict(format!( + "User '{}' on connection '{}' already exists", + row.platform_user_id, row.connection_id + )) + } else { + DbError::Query(e) + } + })?; + } + } + tx.commit().await?; Ok(()) } @@ -197,7 +258,7 @@ impl IChannelRepository for SqliteChannelRepository { id: &str, last_active: aionui_common::TimestampMs, ) -> Result<(), DbError> { - let result = sqlx::query("UPDATE assistant_users SET last_active = ? WHERE owner_user_id = ? AND id = ?") + let result = sqlx::query("UPDATE channel_users SET last_active = ? WHERE owner_user_id = ? AND id = ?") .bind(last_active) .bind(owner_user_id) .bind(id) @@ -209,26 +270,40 @@ impl IChannelRepository for SqliteChannelRepository { Ok(()) } - async fn delete_user(&self, owner_user_id: &str, id: &str) -> Result<(), DbError> { - let result = sqlx::query("DELETE FROM assistant_users WHERE owner_user_id = ? AND id = ?") - .bind(owner_user_id) - .bind(id) - .execute(&self.pool) - .await?; + async fn revoke_user(&self, owner_user_id: &str, id: &str) -> Result<(), DbError> { + let mut tx = self.pool.begin().await?; + let result = sqlx::query( + "UPDATE channel_users SET status = 'revoked', revoked_at = ? \ + WHERE owner_user_id = ? AND id = ? AND status = 'active'", + ) + .bind(aionui_common::now_ms()) + .bind(owner_user_id) + .bind(id) + .execute(&mut *tx) + .await?; if result.rows_affected() == 0 { return Err(DbError::NotFound(format!("User '{id}' not found"))); } + // Soft delete keeps the audit row, so sessions no longer cascade — + // remove them explicitly to stop message routing for this user. The + // owner predicate is defense in depth: the UPDATE above already + // proved ownership within this transaction. + sqlx::query("DELETE FROM channel_conversation_bindings WHERE owner_user_id = ? AND channel_user_id = ?") + .bind(owner_user_id) + .bind(id) + .execute(&mut *tx) + .await?; + tx.commit().await?; Ok(()) } - // ── Session CRUD ───────────────────────────────────────────────── + // ── Conversation binding CRUD ──────────────────────────────────── - async fn get_all_sessions(&self, owner_user_id: &str) -> Result, DbError> { - let rows = sqlx::query_as::<_, AssistantSessionRow>( - "SELECT s.* FROM assistant_sessions s \ - JOIN assistant_users u ON u.id = s.user_id \ - WHERE u.owner_user_id = ? \ - ORDER BY s.last_activity DESC", + async fn get_all_sessions(&self, owner_user_id: &str) -> Result, DbError> { + let rows = sqlx::query_as::<_, ChannelConversationBindingRow>( + "SELECT * FROM channel_conversation_bindings \ + WHERE owner_user_id = ? \ + ORDER BY last_active_at DESC", ) .bind(owner_user_id) .fetch_all(&self.pool) @@ -236,11 +311,13 @@ impl IChannelRepository for SqliteChannelRepository { Ok(rows) } - async fn get_session(&self, owner_user_id: &str, id: &str) -> Result, DbError> { - let row = sqlx::query_as::<_, AssistantSessionRow>( - "SELECT s.* FROM assistant_sessions s \ - JOIN assistant_users u ON u.id = s.user_id \ - WHERE u.owner_user_id = ? AND s.id = ?", + async fn get_session( + &self, + owner_user_id: &str, + id: &str, + ) -> Result, DbError> { + let row = sqlx::query_as::<_, ChannelConversationBindingRow>( + "SELECT * FROM channel_conversation_bindings WHERE owner_user_id = ? AND id = ?", ) .bind(owner_user_id) .bind(id) @@ -254,13 +331,12 @@ impl IChannelRepository for SqliteChannelRepository { owner_user_id: &str, channel_user_id: &str, chat_id: &str, - new_row: &AssistantSessionRow, - ) -> Result { - // Try to find an existing session first. - let existing = sqlx::query_as::<_, AssistantSessionRow>( - "SELECT s.* FROM assistant_sessions s \ - JOIN assistant_users u ON u.id = s.user_id \ - WHERE u.owner_user_id = ? AND s.user_id = ? AND s.chat_id = ?", + new_row: &ChannelConversationBindingRow, + ) -> Result { + // Try to find an existing binding first. + let existing = sqlx::query_as::<_, ChannelConversationBindingRow>( + "SELECT * FROM channel_conversation_bindings \ + WHERE owner_user_id = ? AND channel_user_id = ? AND external_chat_id = ?", ) .bind(owner_user_id) .bind(channel_user_id) @@ -269,50 +345,46 @@ impl IChannelRepository for SqliteChannelRepository { .await?; if let Some(row) = existing { - // Touch last_activity. + // Touch last_active_at. let now = aionui_common::now_ms(); - sqlx::query("UPDATE assistant_sessions SET last_activity = ? WHERE id = ?") + sqlx::query("UPDATE channel_conversation_bindings SET last_active_at = ? WHERE id = ?") .bind(now) .bind(&row.id) .execute(&self.pool) .await?; - return Ok(AssistantSessionRow { + return Ok(ChannelConversationBindingRow { last_activity: now, ..row }); } - // Insert new session. + // Insert a new binding. The owner/connection columns derive from the + // ACTIVE channel user row, so a foreign or revoked channel user makes + // the INSERT match zero rows. A conversation owned by another Core + // user is rejected by the cross-account trigger; the INSERT-side + // EXISTS keeps that failure a clean zero-row no-op instead of an + // opaque trigger abort for the common caller path. sqlx::query( - "INSERT INTO assistant_sessions \ - (id, user_id, agent_type, conversation_id, workspace, \ - chat_id, created_at, last_activity) \ - SELECT ?, ?, ?, ?, ?, ?, ?, ? \ - WHERE EXISTS ( - SELECT 1 FROM assistant_users - WHERE owner_user_id = ? AND id = ? - ) - AND ( - ? IS NULL OR EXISTS ( - SELECT 1 FROM conversations WHERE id = ? AND user_id = ? - ) - )", + "INSERT INTO channel_conversation_bindings \ + (id, owner_user_id, connection_id, channel_user_id, external_chat_id, \ + conversation_id, created_at, last_active_at) \ + SELECT ?, u.owner_user_id, u.connection_id, u.id, ?, ?, ?, ? \ + FROM channel_users u \ + WHERE u.owner_user_id = ? AND u.id = ? AND u.status = 'active' \ + AND ( + ? IS NULL OR EXISTS ( + SELECT 1 FROM conversations WHERE id = ? AND user_id = ? + ) + )", ) .bind(&new_row.id) - .bind(channel_user_id) - .bind(&new_row.agent_type) - .bind(&new_row.conversation_id) - .bind(&new_row.workspace) .bind(&new_row.chat_id) + .bind(&new_row.conversation_id) .bind(new_row.created_at) .bind(new_row.last_activity) .bind(owner_user_id) .bind(channel_user_id) - // Cross-account guard: a bound conversation must belong to the same - // Core owner. NULL conversation_id (the current caller contract) is - // allowed; a conversation owned by another user makes the INSERT match - // zero rows, so get_session below returns NotFound. .bind(&new_row.conversation_id) .bind(&new_row.conversation_id) .bind(owner_user_id) @@ -331,15 +403,12 @@ impl IChannelRepository for SqliteChannelRepository { last_activity: aionui_common::TimestampMs, ) -> Result<(), DbError> { let result = sqlx::query( - "UPDATE assistant_sessions SET last_activity = ? \ - WHERE id = ? AND EXISTS ( - SELECT 1 FROM assistant_users u - WHERE u.id = assistant_sessions.user_id AND u.owner_user_id = ? - )", + "UPDATE channel_conversation_bindings SET last_active_at = ? \ + WHERE owner_user_id = ? AND id = ?", ) .bind(last_activity) - .bind(id) .bind(owner_user_id) + .bind(id) .execute(&self.pool) .await?; if result.rows_affected() == 0 { @@ -356,13 +425,9 @@ impl IChannelRepository for SqliteChannelRepository { ) -> Result<(), DbError> { let now = aionui_common::now_ms(); let result = sqlx::query( - "UPDATE assistant_sessions \ - SET conversation_id = ?, last_activity = ? \ - WHERE id = ? \ - AND EXISTS ( - SELECT 1 FROM assistant_users u - WHERE u.id = assistant_sessions.user_id AND u.owner_user_id = ? - ) + "UPDATE channel_conversation_bindings \ + SET conversation_id = ?, last_active_at = ? \ + WHERE owner_user_id = ? AND id = ? \ AND EXISTS ( SELECT 1 FROM conversations c WHERE c.id = ? AND c.user_id = ? @@ -370,20 +435,19 @@ impl IChannelRepository for SqliteChannelRepository { ) .bind(conversation_id) .bind(now) - .bind(id) .bind(owner_user_id) + .bind(id) .bind(conversation_id) .bind(owner_user_id) .execute(&self.pool) .await?; if result.rows_affected() == 0 { let session_exists = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM assistant_sessions s \ - JOIN assistant_users u ON u.id = s.user_id \ - WHERE s.id = ? AND u.owner_user_id = ?", + "SELECT COUNT(*) FROM channel_conversation_bindings \ + WHERE owner_user_id = ? AND id = ?", ) - .bind(id) .bind(owner_user_id) + .bind(id) .fetch_one(&self.pool) .await?; @@ -408,40 +472,12 @@ impl IChannelRepository for SqliteChannelRepository { Ok(()) } - async fn update_session_agent_type(&self, owner_user_id: &str, id: &str, agent_type: &str) -> Result<(), DbError> { - let now = aionui_common::now_ms(); - let result = sqlx::query( - "UPDATE assistant_sessions \ - SET agent_type = ?, last_activity = ? \ - WHERE id = ? AND EXISTS ( - SELECT 1 FROM assistant_users u - WHERE u.id = assistant_sessions.user_id AND u.owner_user_id = ? - )", - ) - .bind(agent_type) - .bind(now) - .bind(id) - .bind(owner_user_id) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(DbError::NotFound(format!("Session '{id}' not found"))); - } - Ok(()) - } - async fn delete_sessions_by_user(&self, owner_user_id: &str, channel_user_id: &str) -> Result<(), DbError> { - sqlx::query( - "DELETE FROM assistant_sessions \ - WHERE user_id = ? AND EXISTS ( - SELECT 1 FROM assistant_users u - WHERE u.id = assistant_sessions.user_id AND u.owner_user_id = ? - )", - ) - .bind(channel_user_id) - .bind(owner_user_id) - .execute(&self.pool) - .await?; + sqlx::query("DELETE FROM channel_conversation_bindings WHERE owner_user_id = ? AND channel_user_id = ?") + .bind(owner_user_id) + .bind(channel_user_id) + .execute(&self.pool) + .await?; Ok(()) } @@ -452,42 +488,41 @@ impl IChannelRepository for SqliteChannelRepository { chat_id: &str, ) -> Result<(), DbError> { sqlx::query( - "DELETE FROM assistant_sessions \ - WHERE user_id = ? AND chat_id = ? AND EXISTS ( - SELECT 1 FROM assistant_users u - WHERE u.id = assistant_sessions.user_id AND u.owner_user_id = ? - )", + "DELETE FROM channel_conversation_bindings \ + WHERE owner_user_id = ? AND channel_user_id = ? AND external_chat_id = ?", ) + .bind(owner_user_id) .bind(channel_user_id) .bind(chat_id) - .bind(owner_user_id) .execute(&self.pool) .await?; Ok(()) } - // ── Pairing Codes ──────────────────────────────────────────────── + // ── Pairing requests ───────────────────────────────────────────── - async fn create_pairing(&self, owner_user_id: &str, row: &PairingCodeRow) -> Result<(), DbError> { + async fn create_pairing(&self, owner_user_id: &str, row: &ChannelPairingRequestRow) -> Result<(), DbError> { sqlx::query( - "INSERT INTO assistant_pairing_codes \ - (code, owner_user_id, platform_user_id, platform_type, display_name, \ - requested_at, expires_at, status) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO channel_pairing_requests \ + (id, owner_user_id, connection_id, external_user_id, display_name, \ + code_hash, status, requested_at, expires_at, approved_channel_user_id) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) - .bind(&row.code) + .bind(&row.id) .bind(owner_user_id) + .bind(&row.connection_id) .bind(&row.platform_user_id) - .bind(&row.platform_type) .bind(&row.display_name) + .bind(&row.code_hash) + .bind(&row.status) .bind(row.requested_at) .bind(row.expires_at) - .bind(&row.status) + .bind(&row.approved_channel_user_id) .execute(&self.pool) .await .map_err(|e| { if is_unique_violation(&e) { - DbError::Conflict(format!("Pairing code '{}' already exists", row.code)) + DbError::Conflict("A pending pairing request already exists for this user or code".into()) } else { DbError::Query(e) } @@ -495,11 +530,13 @@ impl IChannelRepository for SqliteChannelRepository { Ok(()) } - async fn get_pending_pairings(&self, owner_user_id: &str) -> Result, DbError> { - let rows = sqlx::query_as::<_, PairingCodeRow>( - "SELECT * FROM assistant_pairing_codes \ - WHERE owner_user_id = ? AND status = 'pending' \ - ORDER BY requested_at DESC", + async fn get_pending_pairings(&self, owner_user_id: &str) -> Result, DbError> { + let rows = sqlx::query_as::<_, ChannelPairingRequestRow>( + "SELECT p.*, c.plugin_key AS platform_type \ + FROM channel_pairing_requests p \ + JOIN channel_connections c ON c.owner_user_id = p.owner_user_id AND c.id = p.connection_id \ + WHERE p.owner_user_id = ? AND p.status = 'pending' \ + ORDER BY p.requested_at DESC", ) .bind(owner_user_id) .fetch_all(&self.pool) @@ -507,37 +544,89 @@ impl IChannelRepository for SqliteChannelRepository { Ok(rows) } - async fn get_pairing_by_code(&self, owner_user_id: &str, code: &str) -> Result, DbError> { - let row = sqlx::query_as::<_, PairingCodeRow>( - "SELECT * FROM assistant_pairing_codes WHERE owner_user_id = ? AND code = ?", + async fn get_pairing(&self, owner_user_id: &str, id: &str) -> Result, DbError> { + let row = sqlx::query_as::<_, ChannelPairingRequestRow>( + "SELECT p.*, c.plugin_key AS platform_type \ + FROM channel_pairing_requests p \ + JOIN channel_connections c ON c.owner_user_id = p.owner_user_id AND c.id = p.connection_id \ + WHERE p.owner_user_id = ? AND p.id = ?", ) .bind(owner_user_id) - .bind(code) + .bind(id) .fetch_optional(&self.pool) .await?; Ok(row) } - async fn update_pairing_status(&self, owner_user_id: &str, code: &str, status: &str) -> Result<(), DbError> { - let result = sqlx::query("UPDATE assistant_pairing_codes SET status = ? WHERE owner_user_id = ? AND code = ?") - .bind(status) - .bind(owner_user_id) - .bind(code) - .execute(&self.pool) - .await?; + async fn get_pending_pairing_by_code_hash( + &self, + owner_user_id: &str, + code_hash: &str, + ) -> Result, DbError> { + let row = sqlx::query_as::<_, ChannelPairingRequestRow>( + "SELECT p.*, c.plugin_key AS platform_type \ + FROM channel_pairing_requests p \ + JOIN channel_connections c ON c.owner_user_id = p.owner_user_id AND c.id = p.connection_id \ + WHERE p.owner_user_id = ? AND p.code_hash = ? AND p.status = 'pending'", + ) + .bind(owner_user_id) + .bind(code_hash) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } + + async fn update_pairing_status( + &self, + owner_user_id: &str, + id: &str, + status: &str, + approved_channel_user_id: Option<&str>, + ) -> Result<(), DbError> { + let result = sqlx::query( + "UPDATE channel_pairing_requests \ + SET status = ?, approved_channel_user_id = COALESCE(?, approved_channel_user_id) \ + WHERE owner_user_id = ? AND id = ?", + ) + .bind(status) + .bind(approved_channel_user_id) + .bind(owner_user_id) + .bind(id) + .execute(&self.pool) + .await?; if result.rows_affected() == 0 { - return Err(DbError::NotFound(format!("Pairing code '{code}' not found"))); + return Err(DbError::NotFound(format!("Pairing request '{id}' not found"))); } Ok(()) } + async fn expire_pending_pairings_for_user( + &self, + owner_user_id: &str, + connection_id: &str, + external_user_id: &str, + ) -> Result { + let result = sqlx::query( + "UPDATE channel_pairing_requests \ + SET status = 'expired' \ + WHERE owner_user_id = ? AND connection_id = ? AND external_user_id = ? \ + AND status = 'pending'", + ) + .bind(owner_user_id) + .bind(connection_id) + .bind(external_user_id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + async fn cleanup_expired_pairings( &self, owner_user_id: &str, now: aionui_common::TimestampMs, ) -> Result { let result = sqlx::query( - "UPDATE assistant_pairing_codes \ + "UPDATE channel_pairing_requests \ SET status = 'expired' \ WHERE owner_user_id = ? AND status = 'pending' AND expires_at <= ?", ) @@ -587,12 +676,12 @@ mod tests { .unwrap(); } - fn sample_plugin() -> ChannelPluginRow { + fn sample_connection() -> ChannelConnectionRow { let now = aionui_common::now_ms(); - ChannelPluginRow { + ChannelConnectionRow { id: "tg-1".into(), owner_user_id: OWNER_A.into(), - r#type: "telegram".into(), + plugin_key: "telegram".into(), name: "My Telegram Bot".into(), enabled: false, config: r#"{"credentials":{"token":"enc_xxx"}}"#.into(), @@ -603,66 +692,94 @@ mod tests { } } - fn sample_user() -> AssistantUserRow { + /// Seeds the connection row users/pairings attach to (FK parent). + /// Connection identity is per-owner, so the same id is seeded per owner. + async fn seed_connection(repo: &SqliteChannelRepository, owner: &str) { + repo.upsert_connection( + owner, + &ChannelConnectionRow { + owner_user_id: owner.into(), + ..sample_connection() + }, + ) + .await + .unwrap(); + } + + fn sample_user() -> ChannelUserRow { let now = aionui_common::now_ms(); - AssistantUserRow { + ChannelUserRow { id: "usr-1".into(), owner_user_id: OWNER_A.into(), + connection_id: "tg-1".into(), platform_user_id: "tg_12345".into(), platform_type: "telegram".into(), display_name: Some("Alice".into()), + status: "active".into(), + revoked_at: None, authorized_at: now, last_active: None, - session_id: None, } } - fn sample_session(user_id: &str) -> AssistantSessionRow { + /// Seeds the FK-parent connection and authorizes the sample user on it. + async fn seed_user(repo: &SqliteChannelRepository) { + seed_connection(repo, OWNER_A).await; + repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + } + + /// `owner_user_id`/`connection_id` are left empty on purpose: the INSERT + /// derives both from the active `channel_users` row, so a caller-supplied + /// value is never trusted. + fn sample_session(user_id: &str) -> ChannelConversationBindingRow { let now = aionui_common::now_ms(); - AssistantSessionRow { + ChannelConversationBindingRow { id: "sess-1".into(), + owner_user_id: String::new(), + connection_id: String::new(), user_id: user_id.into(), - agent_type: "gemini".into(), - conversation_id: None, - workspace: None, chat_id: Some("chat-abc".into()), + conversation_id: None, created_at: now, last_activity: now, } } - fn sample_pairing() -> PairingCodeRow { + fn sample_pairing() -> ChannelPairingRequestRow { let now = aionui_common::now_ms(); - PairingCodeRow { - code: "123456".into(), + ChannelPairingRequestRow { + id: "pair-1".into(), owner_user_id: OWNER_A.into(), + connection_id: "tg-1".into(), platform_user_id: "tg_99".into(), platform_type: "telegram".into(), display_name: Some("Bob".into()), + code_hash: "hash-123456".into(), + status: "pending".into(), requested_at: now, expires_at: now + 600_000, - status: "pending".into(), + approved_channel_user_id: None, } } // ── Plugin tests ───────────────────────────────────────────────── #[tokio::test] - async fn get_all_plugins_empty() { + async fn get_all_connections_empty() { let (repo, _db) = setup().await; - let plugins = repo.get_all_plugins(OWNER_A).await.unwrap(); + let plugins = repo.get_all_connections(OWNER_A).await.unwrap(); assert!(plugins.is_empty()); } #[tokio::test] async fn upsert_and_get_plugin() { let (repo, _db) = setup().await; - let plugin = sample_plugin(); - repo.upsert_plugin(OWNER_A, &plugin).await.unwrap(); + let plugin = sample_connection(); + repo.upsert_connection(OWNER_A, &plugin).await.unwrap(); - let found = repo.get_plugin(OWNER_A, "tg-1").await.unwrap().unwrap(); + let found = repo.get_connection(OWNER_A, "tg-1").await.unwrap().unwrap(); assert_eq!(found.id, "tg-1"); - assert_eq!(found.r#type, "telegram"); + assert_eq!(found.plugin_key, "telegram"); assert_eq!(found.name, "My Telegram Bot"); assert!(!found.enabled); } @@ -670,32 +787,32 @@ mod tests { #[tokio::test] async fn upsert_plugin_updates_existing() { let (repo, _db) = setup().await; - let plugin = sample_plugin(); - repo.upsert_plugin(OWNER_A, &plugin).await.unwrap(); + let plugin = sample_connection(); + repo.upsert_connection(OWNER_A, &plugin).await.unwrap(); - let updated = ChannelPluginRow { + let updated = ChannelConnectionRow { name: "Updated Bot".into(), enabled: true, updated_at: aionui_common::now_ms(), ..plugin }; - repo.upsert_plugin(OWNER_A, &updated).await.unwrap(); + repo.upsert_connection(OWNER_A, &updated).await.unwrap(); - let found = repo.get_plugin(OWNER_A, "tg-1").await.unwrap().unwrap(); + let found = repo.get_connection(OWNER_A, "tg-1").await.unwrap().unwrap(); assert_eq!(found.name, "Updated Bot"); assert!(found.enabled); } #[tokio::test] - async fn get_all_plugins_returns_multiple() { + async fn get_all_connections_returns_multiple() { let (repo, _db) = setup().await; - repo.upsert_plugin(OWNER_A, &sample_plugin()).await.unwrap(); + repo.upsert_connection(OWNER_A, &sample_connection()).await.unwrap(); let now = aionui_common::now_ms(); - let lark = ChannelPluginRow { + let lark = ChannelConnectionRow { id: "lark-1".into(), owner_user_id: OWNER_A.into(), - r#type: "lark".into(), + plugin_key: "lark".into(), name: "Lark Bot".into(), enabled: true, config: "{}".into(), @@ -704,9 +821,9 @@ mod tests { created_at: now, updated_at: now, }; - repo.upsert_plugin(OWNER_A, &lark).await.unwrap(); + repo.upsert_connection(OWNER_A, &lark).await.unwrap(); - let all = repo.get_all_plugins(OWNER_A).await.unwrap(); + let all = repo.get_all_connections(OWNER_A).await.unwrap(); assert_eq!(all.len(), 2); } @@ -715,10 +832,10 @@ mod tests { let (repo, db) = setup().await; create_owner(db.pool(), OWNER_B).await; - repo.upsert_plugin(OWNER_A, &sample_plugin()).await.unwrap(); + repo.upsert_connection(OWNER_A, &sample_connection()).await.unwrap(); - assert!(repo.get_plugin(OWNER_B, "tg-1").await.unwrap().is_none()); - assert!(repo.get_all_plugins(OWNER_B).await.unwrap().is_empty()); + assert!(repo.get_connection(OWNER_B, "tg-1").await.unwrap().is_none()); + assert!(repo.get_all_connections(OWNER_B).await.unwrap().is_empty()); } #[tokio::test] @@ -726,28 +843,28 @@ mod tests { let (repo, db) = setup().await; create_owner(db.pool(), OWNER_B).await; - let owner_a_plugin = sample_plugin(); - let owner_b_plugin = ChannelPluginRow { + let owner_a_plugin = sample_connection(); + let owner_b_plugin = ChannelConnectionRow { owner_user_id: OWNER_B.into(), name: "Owner B Telegram Bot".into(), enabled: true, - ..sample_plugin() + ..sample_connection() }; - repo.upsert_plugin(OWNER_A, &owner_a_plugin).await.unwrap(); - repo.upsert_plugin(OWNER_B, &owner_b_plugin).await.unwrap(); + repo.upsert_connection(OWNER_A, &owner_a_plugin).await.unwrap(); + repo.upsert_connection(OWNER_B, &owner_b_plugin).await.unwrap(); - let owner_a_found = repo.get_plugin(OWNER_A, "tg-1").await.unwrap().unwrap(); - let owner_b_found = repo.get_plugin(OWNER_B, "tg-1").await.unwrap().unwrap(); + let owner_a_found = repo.get_connection(OWNER_A, "tg-1").await.unwrap().unwrap(); + let owner_b_found = repo.get_connection(OWNER_B, "tg-1").await.unwrap().unwrap(); assert_eq!(owner_a_found.id, "tg-1"); assert_eq!(owner_b_found.id, "tg-1"); assert_eq!(owner_a_found.name, "My Telegram Bot"); assert_eq!(owner_b_found.name, "Owner B Telegram Bot"); - repo.update_plugin_status( + repo.update_connection_status( OWNER_B, "tg-1", - &UpdatePluginStatusParams { + &UpdateConnectionStatusParams { status: Some("running".into()), last_connected: None, enabled: None, @@ -756,22 +873,22 @@ mod tests { .await .unwrap(); - let owner_a_after = repo.get_plugin(OWNER_A, "tg-1").await.unwrap().unwrap(); - let owner_b_after = repo.get_plugin(OWNER_B, "tg-1").await.unwrap().unwrap(); + let owner_a_after = repo.get_connection(OWNER_A, "tg-1").await.unwrap().unwrap(); + let owner_b_after = repo.get_connection(OWNER_B, "tg-1").await.unwrap().unwrap(); assert_eq!(owner_a_after.status, None); assert_eq!(owner_b_after.status, Some("running".into())); } #[tokio::test] - async fn update_plugin_status_sets_fields() { + async fn update_connection_status_sets_fields() { let (repo, _db) = setup().await; - repo.upsert_plugin(OWNER_A, &sample_plugin()).await.unwrap(); + repo.upsert_connection(OWNER_A, &sample_connection()).await.unwrap(); let now = aionui_common::now_ms(); - repo.update_plugin_status( + repo.update_connection_status( OWNER_A, "tg-1", - &UpdatePluginStatusParams { + &UpdateConnectionStatusParams { status: Some("running".into()), last_connected: Some(now), enabled: Some(true), @@ -780,20 +897,20 @@ mod tests { .await .unwrap(); - let found = repo.get_plugin(OWNER_A, "tg-1").await.unwrap().unwrap(); + let found = repo.get_connection(OWNER_A, "tg-1").await.unwrap().unwrap(); assert_eq!(found.status.as_deref(), Some("running")); assert_eq!(found.last_connected, Some(now)); assert!(found.enabled); } #[tokio::test] - async fn update_plugin_status_not_found() { + async fn update_connection_status_not_found() { let (repo, _db) = setup().await; let err = repo - .update_plugin_status( + .update_connection_status( OWNER_A, "nope", - &UpdatePluginStatusParams { + &UpdateConnectionStatusParams { status: Some("error".into()), ..Default::default() }, @@ -804,11 +921,11 @@ mod tests { } #[tokio::test] - async fn update_plugin_status_empty_params_is_noop() { + async fn update_connection_status_empty_params_is_noop() { let (repo, _db) = setup().await; - repo.upsert_plugin(OWNER_A, &sample_plugin()).await.unwrap(); + repo.upsert_connection(OWNER_A, &sample_connection()).await.unwrap(); // No fields to update → no-op, no error. - repo.update_plugin_status(OWNER_A, "tg-1", &UpdatePluginStatusParams::default()) + repo.update_connection_status(OWNER_A, "tg-1", &UpdateConnectionStatusParams::default()) .await .unwrap(); } @@ -816,15 +933,15 @@ mod tests { #[tokio::test] async fn delete_plugin_removes_row() { let (repo, _db) = setup().await; - repo.upsert_plugin(OWNER_A, &sample_plugin()).await.unwrap(); - repo.delete_plugin(OWNER_A, "tg-1").await.unwrap(); - assert!(repo.get_plugin(OWNER_A, "tg-1").await.unwrap().is_none()); + repo.upsert_connection(OWNER_A, &sample_connection()).await.unwrap(); + repo.delete_connection(OWNER_A, "tg-1").await.unwrap(); + assert!(repo.get_connection(OWNER_A, "tg-1").await.unwrap().is_none()); } #[tokio::test] async fn delete_plugin_not_found() { let (repo, _db) = setup().await; - let err = repo.delete_plugin(OWNER_A, "nope").await.unwrap_err(); + let err = repo.delete_connection(OWNER_A, "nope").await.unwrap_err(); assert!(matches!(err, DbError::NotFound(_))); } @@ -840,6 +957,7 @@ mod tests { #[tokio::test] async fn create_and_get_user_by_platform() { let (repo, _db) = setup().await; + seed_connection(&repo, OWNER_A).await; let user = sample_user(); repo.create_user(OWNER_A, &user).await.unwrap(); @@ -855,9 +973,9 @@ mod tests { #[tokio::test] async fn create_duplicate_user_returns_conflict() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; - let dup = AssistantUserRow { + let dup = ChannelUserRow { id: "usr-2".into(), ..sample_user() }; @@ -865,13 +983,49 @@ mod tests { assert!(matches!(err, DbError::Conflict(_))); } + #[tokio::test] + async fn create_user_reactivates_revoked_row() { + let (repo, _db) = setup().await; + seed_user(&repo).await; + repo.revoke_user(OWNER_A, "usr-1").await.unwrap(); + + // Re-authorizing the same identity reuses the revoked audit row + // instead of inserting a second one. + let again = ChannelUserRow { + id: "usr-2".into(), + display_name: Some("Alice Again".into()), + ..sample_user() + }; + repo.create_user(OWNER_A, &again).await.unwrap(); + + let found = repo + .get_user_by_platform(OWNER_A, "tg_12345", "telegram") + .await + .unwrap() + .unwrap(); + assert_eq!(found.id, "usr-1"); + assert_eq!(found.status, "active"); + assert_eq!(found.revoked_at, None); + assert_eq!(found.display_name.as_deref(), Some("Alice Again")); + + let row_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM channel_users WHERE owner_user_id = ? AND external_user_id = ?") + .bind(OWNER_A) + .bind("tg_12345") + .fetch_one(&repo.pool) + .await + .unwrap(); + assert_eq!(row_count, 1); + } + #[tokio::test] async fn platform_users_are_filtered_by_owner() { let (repo, db) = setup().await; create_owner(db.pool(), OWNER_B).await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); - let other = AssistantUserRow { + seed_user(&repo).await; + seed_connection(&repo, OWNER_B).await; + let other = ChannelUserRow { id: "usr-2".into(), owner_user_id: OWNER_B.into(), platform_user_id: "tg_other".into(), @@ -905,8 +1059,9 @@ mod tests { let (repo, db) = setup().await; create_owner(db.pool(), OWNER_B).await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); - let other_owner_user = AssistantUserRow { + seed_user(&repo).await; + seed_connection(&repo, OWNER_B).await; + let other_owner_user = ChannelUserRow { id: "usr-2".into(), owner_user_id: OWNER_B.into(), ..sample_user() @@ -941,7 +1096,7 @@ mod tests { #[tokio::test] async fn update_user_last_active_updates_timestamp() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let new_ts = aionui_common::now_ms() + 5000; repo.update_user_last_active(OWNER_A, "usr-1", new_ts).await.unwrap(); @@ -962,42 +1117,74 @@ mod tests { } #[tokio::test] - async fn delete_user_removes_row() { + async fn revoke_user_hides_user_but_keeps_audit_row() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); - repo.delete_user(OWNER_A, "usr-1").await.unwrap(); + seed_user(&repo).await; + repo.revoke_user(OWNER_A, "usr-1").await.unwrap(); + + // Revoked users disappear from every read path. assert!( repo.get_user_by_platform(OWNER_A, "tg_12345", "telegram") .await .unwrap() .is_none() ); + assert!(repo.get_all_users(OWNER_A).await.unwrap().is_empty()); + + // The authorization history survives as an audit row. + let (status, revoked_at): (String, Option) = + sqlx::query_as("SELECT status, revoked_at FROM channel_users WHERE id = ?") + .bind("usr-1") + .fetch_one(&repo.pool) + .await + .unwrap(); + assert_eq!(status, "revoked"); + assert!(revoked_at.is_some()); } #[tokio::test] - async fn delete_user_not_found() { + async fn revoke_user_not_found() { let (repo, _db) = setup().await; - let err = repo.delete_user(OWNER_A, "nope").await.unwrap_err(); + let err = repo.revoke_user(OWNER_A, "nope").await.unwrap_err(); assert!(matches!(err, DbError::NotFound(_))); } #[tokio::test] - async fn delete_user_cascades_sessions() { + async fn revoke_user_twice_is_not_found() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; + repo.revoke_user(OWNER_A, "usr-1").await.unwrap(); + + // Only an ACTIVE row can be revoked. + let err = repo.revoke_user(OWNER_A, "usr-1").await.unwrap_err(); + assert!(matches!(err, DbError::NotFound(_))); + } + + #[tokio::test] + async fn revoke_user_deletes_sessions() { + let (repo, _db) = setup().await; + seed_user(&repo).await; let session = sample_session("usr-1"); repo.get_or_create_session(OWNER_A, "usr-1", "chat-abc", &session) .await .unwrap(); - // Sessions exist before delete. + // Sessions exist before revocation. assert_eq!(repo.get_all_sessions(OWNER_A).await.unwrap().len(), 1); - repo.delete_user(OWNER_A, "usr-1").await.unwrap(); + repo.revoke_user(OWNER_A, "usr-1").await.unwrap(); - // Sessions cascade-deleted. + // Soft delete keeps the user row, but message routing stops: the + // sessions are removed outright, not merely hidden behind the join. assert!(repo.get_all_sessions(OWNER_A).await.unwrap().is_empty()); + let session_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM channel_conversation_bindings WHERE channel_user_id = ?") + .bind("usr-1") + .fetch_one(&repo.pool) + .await + .unwrap(); + assert_eq!(session_count, 0); } // ── Session tests ──────────────────────────────────────────────── @@ -1011,7 +1198,7 @@ mod tests { #[tokio::test] async fn get_or_create_session_creates_new() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let new = sample_session("usr-1"); let result = repo @@ -1026,7 +1213,7 @@ mod tests { #[tokio::test] async fn get_or_create_session_reuses_existing() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let new = sample_session("usr-1"); let first = repo @@ -1035,7 +1222,7 @@ mod tests { .unwrap(); // Second call with different new_row id should still return the first. - let another = AssistantSessionRow { + let another = ChannelConversationBindingRow { id: "sess-2".into(), ..new }; @@ -1051,14 +1238,14 @@ mod tests { #[tokio::test] async fn per_chat_isolation_different_chats() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let s1 = sample_session("usr-1"); repo.get_or_create_session(OWNER_A, "usr-1", "chat-abc", &s1) .await .unwrap(); - let s2 = AssistantSessionRow { + let s2 = ChannelConversationBindingRow { id: "sess-2".into(), chat_id: Some("chat-xyz".into()), ..sample_session("usr-1") @@ -1073,7 +1260,7 @@ mod tests { #[tokio::test] async fn get_session_by_id() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let new = sample_session("usr-1"); repo.get_or_create_session(OWNER_A, "usr-1", "chat-abc", &new) @@ -1081,7 +1268,12 @@ mod tests { .unwrap(); let found = repo.get_session(OWNER_A, "sess-1").await.unwrap().unwrap(); - assert_eq!(found.agent_type, "gemini"); + assert_eq!(found.user_id, "usr-1"); + assert_eq!(found.chat_id.as_deref(), Some("chat-abc")); + // Owner and connection are derived from the channel user, not taken + // from the caller-supplied row (which left both empty). + assert_eq!(found.owner_user_id, OWNER_A); + assert_eq!(found.connection_id, "tg-1"); } #[tokio::test] @@ -1093,7 +1285,7 @@ mod tests { #[tokio::test] async fn update_session_activity_updates_timestamp() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let new = sample_session("usr-1"); repo.get_or_create_session(OWNER_A, "usr-1", "chat-abc", &new) @@ -1117,14 +1309,14 @@ mod tests { #[tokio::test] async fn delete_sessions_by_user_removes_all() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let s1 = sample_session("usr-1"); repo.get_or_create_session(OWNER_A, "usr-1", "chat-abc", &s1) .await .unwrap(); - let s2 = AssistantSessionRow { + let s2 = ChannelConversationBindingRow { id: "sess-2".into(), chat_id: Some("chat-xyz".into()), ..sample_session("usr-1") @@ -1163,7 +1355,7 @@ mod tests { #[tokio::test] async fn update_session_conversation_persists() { let (repo, db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let new = sample_session("usr-1"); repo.get_or_create_session(OWNER_A, "usr-1", "chat-abc", &new) @@ -1184,7 +1376,7 @@ mod tests { async fn update_session_conversation_rejects_cross_owner_conversation() { let (repo, db) = setup().await; create_owner(db.pool(), OWNER_B).await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let new = sample_session("usr-1"); repo.get_or_create_session(OWNER_A, "usr-1", "chat-abc", &new) @@ -1221,48 +1413,64 @@ mod tests { assert!(matches!(err, DbError::NotFound(_))); } + /// Replaces the pre-A3 `update_session_agent_type` coverage: agent + /// configuration is no longer a binding column, so what the binding must + /// now guarantee is that its owner/connection identity comes from the + /// channel user rather than from the caller. #[tokio::test] - async fn update_session_agent_type_persists() { + async fn get_or_create_session_derives_owner_and_connection_ignoring_caller_values() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; - let new = sample_session("usr-1"); - repo.get_or_create_session(OWNER_A, "usr-1", "chat-abc", &new) + // A caller that lies about owner/connection must not be believed. + let new = ChannelConversationBindingRow { + owner_user_id: "attacker".into(), + connection_id: "forged-connection".into(), + ..sample_session("usr-1") + }; + let created = repo + .get_or_create_session(OWNER_A, "usr-1", "chat-abc", &new) .await .unwrap(); - - assert_eq!( - repo.get_session(OWNER_A, "sess-1").await.unwrap().unwrap().agent_type, - "gemini" - ); - - repo.update_session_agent_type(OWNER_A, "sess-1", "acp").await.unwrap(); + assert_eq!(created.owner_user_id, OWNER_A); + assert_eq!(created.connection_id, "tg-1"); let found = repo.get_session(OWNER_A, "sess-1").await.unwrap().unwrap(); - assert_eq!(found.agent_type, "acp"); + assert_eq!(found.owner_user_id, OWNER_A); + assert_eq!(found.connection_id, "tg-1"); } + /// A revoked channel user is no longer routable: the derive-side INSERT + /// filters on `status = 'active'`, so no new binding can be created. #[tokio::test] - async fn update_session_agent_type_not_found() { + async fn get_or_create_session_rejects_revoked_channel_user() { let (repo, _db) = setup().await; + seed_user(&repo).await; + + repo.revoke_user(OWNER_A, "usr-1").await.unwrap(); + let err = repo - .update_session_agent_type(OWNER_A, "nope", "acp") + .get_or_create_session(OWNER_A, "usr-1", "chat-abc", &sample_session("usr-1")) .await .unwrap_err(); - assert!(matches!(err, DbError::NotFound(_))); + assert!( + matches!(err, DbError::NotFound(_)), + "revoked user must not get a binding, got: {err:?}" + ); + assert!(repo.get_all_sessions(OWNER_A).await.unwrap().is_empty()); } #[tokio::test] async fn delete_session_by_user_chat_removes_only_target() { let (repo, _db) = setup().await; - repo.create_user(OWNER_A, &sample_user()).await.unwrap(); + seed_user(&repo).await; let s1 = sample_session("usr-1"); repo.get_or_create_session(OWNER_A, "usr-1", "chat-abc", &s1) .await .unwrap(); - let s2 = AssistantSessionRow { + let s2 = ChannelConversationBindingRow { id: "sess-2".into(), chat_id: Some("chat-xyz".into()), ..sample_session("usr-1") @@ -1294,19 +1502,63 @@ mod tests { #[tokio::test] async fn create_and_get_pairing() { let (repo, _db) = setup().await; + seed_connection(&repo, OWNER_A).await; let pairing = sample_pairing(); repo.create_pairing(OWNER_A, &pairing).await.unwrap(); - let found = repo.get_pairing_by_code(OWNER_A, "123456").await.unwrap().unwrap(); + // Addressable by surrogate id … + let found = repo.get_pairing(OWNER_A, "pair-1").await.unwrap().unwrap(); assert_eq!(found.platform_user_id, "tg_99"); assert_eq!(found.status, "pending"); + // … and platform_type is derived from the joined connection. + assert_eq!(found.platform_type, "telegram"); + + // … and by code hash, while pending. + let by_hash = repo + .get_pending_pairing_by_code_hash(OWNER_A, "hash-123456") + .await + .unwrap() + .unwrap(); + assert_eq!(by_hash.id, "pair-1"); + } + + /// The plaintext code must never reach the database: only `code_hash` + /// is stored, and the table has no column that could hold the code. + #[tokio::test] + async fn pairing_stores_only_the_code_hash() { + let (repo, _db) = setup().await; + seed_connection(&repo, OWNER_A).await; + repo.create_pairing(OWNER_A, &sample_pairing()).await.unwrap(); + + let columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info('channel_pairing_requests')") + .fetch_all(&repo.pool) + .await + .unwrap(); + assert!( + !columns.iter().any(|c| c == "code"), + "pairing table must not carry a plaintext code column: {columns:?}" + ); + + let stored: String = sqlx::query_scalar("SELECT code_hash FROM channel_pairing_requests WHERE id = ?") + .bind("pair-1") + .fetch_one(&repo.pool) + .await + .unwrap(); + assert_eq!(stored, "hash-123456"); + assert_ne!(stored, "123456"); } #[tokio::test] async fn create_duplicate_pairing_returns_conflict() { let (repo, _db) = setup().await; + seed_connection(&repo, OWNER_A).await; repo.create_pairing(OWNER_A, &sample_pairing()).await.unwrap(); - let err = repo.create_pairing(OWNER_A, &sample_pairing()).await.unwrap_err(); + let second = ChannelPairingRequestRow { + id: "pair-2".into(), + ..sample_pairing() + }; + // One pending request per (owner, connection, external user). + let err = repo.create_pairing(OWNER_A, &second).await.unwrap_err(); assert!(matches!(err, DbError::Conflict(_))); } @@ -1314,28 +1566,46 @@ mod tests { async fn pairing_lookup_is_filtered_by_owner() { let (repo, db) = setup().await; create_owner(db.pool(), OWNER_B).await; + seed_connection(&repo, OWNER_A).await; repo.create_pairing(OWNER_A, &sample_pairing()).await.unwrap(); - assert!(repo.get_pairing_by_code(OWNER_B, "123456").await.unwrap().is_none()); + assert!(repo.get_pairing(OWNER_B, "pair-1").await.unwrap().is_none()); + assert!( + repo.get_pending_pairing_by_code_hash(OWNER_B, "hash-123456") + .await + .unwrap() + .is_none() + ); assert!(repo.get_pending_pairings(OWNER_B).await.unwrap().is_empty()); } #[tokio::test] - async fn same_pairing_code_can_exist_for_different_owners() { + async fn same_code_hash_can_exist_for_different_owners() { let (repo, db) = setup().await; create_owner(db.pool(), OWNER_B).await; + seed_connection(&repo, OWNER_A).await; + seed_connection(&repo, OWNER_B).await; repo.create_pairing(OWNER_A, &sample_pairing()).await.unwrap(); - let owner_b_pairing = PairingCodeRow { + let owner_b_pairing = ChannelPairingRequestRow { + id: "pair-2".into(), owner_user_id: OWNER_B.into(), platform_user_id: "tg_owner_b".into(), ..sample_pairing() }; repo.create_pairing(OWNER_B, &owner_b_pairing).await.unwrap(); - let owner_a = repo.get_pairing_by_code(OWNER_A, "123456").await.unwrap().unwrap(); - let owner_b = repo.get_pairing_by_code(OWNER_B, "123456").await.unwrap().unwrap(); + let owner_a = repo + .get_pending_pairing_by_code_hash(OWNER_A, "hash-123456") + .await + .unwrap() + .unwrap(); + let owner_b = repo + .get_pending_pairing_by_code_hash(OWNER_B, "hash-123456") + .await + .unwrap() + .unwrap(); assert_eq!(owner_a.platform_user_id, "tg_99"); assert_eq!(owner_b.platform_user_id, "tg_owner_b"); } @@ -1343,11 +1613,14 @@ mod tests { #[tokio::test] async fn get_pending_pairings_filters_by_status() { let (repo, _db) = setup().await; + seed_connection(&repo, OWNER_A).await; let p1 = sample_pairing(); repo.create_pairing(OWNER_A, &p1).await.unwrap(); - let p2 = PairingCodeRow { - code: "654321".into(), + let p2 = ChannelPairingRequestRow { + id: "pair-2".into(), + platform_user_id: "tg_100".into(), + code_hash: "hash-654321".into(), status: "approved".into(), ..sample_pairing() }; @@ -1355,52 +1628,122 @@ mod tests { let pending = repo.get_pending_pairings(OWNER_A).await.unwrap(); assert_eq!(pending.len(), 1); - assert_eq!(pending[0].code, "123456"); + assert_eq!(pending[0].id, "pair-1"); + assert_eq!(pending[0].code_hash, "hash-123456"); + } + + #[tokio::test] + async fn pairing_lookups_not_found() { + let (repo, _db) = setup().await; + assert!(repo.get_pairing(OWNER_A, "nope").await.unwrap().is_none()); + assert!( + repo.get_pending_pairing_by_code_hash(OWNER_A, "hash-000000") + .await + .unwrap() + .is_none() + ); } + /// A non-pending request is invisible to the code-hash lookup, so an + /// already-used code cannot be replayed. #[tokio::test] - async fn get_pairing_by_code_not_found() { + async fn code_hash_lookup_ignores_non_pending() { let (repo, _db) = setup().await; - assert!(repo.get_pairing_by_code(OWNER_A, "000000").await.unwrap().is_none()); + seed_connection(&repo, OWNER_A).await; + repo.create_pairing(OWNER_A, &sample_pairing()).await.unwrap(); + repo.update_pairing_status(OWNER_A, "pair-1", "rejected", None) + .await + .unwrap(); + + assert!( + repo.get_pending_pairing_by_code_hash(OWNER_A, "hash-123456") + .await + .unwrap() + .is_none() + ); + // The row itself is still addressable by id. + assert_eq!( + repo.get_pairing(OWNER_A, "pair-1").await.unwrap().unwrap().status, + "rejected" + ); } #[tokio::test] - async fn update_pairing_status_changes_status() { + async fn update_pairing_status_records_approved_user() { let (repo, _db) = setup().await; + seed_user(&repo).await; repo.create_pairing(OWNER_A, &sample_pairing()).await.unwrap(); - repo.update_pairing_status(OWNER_A, "123456", "approved").await.unwrap(); + repo.update_pairing_status(OWNER_A, "pair-1", "approved", Some("usr-1")) + .await + .unwrap(); - let found = repo.get_pairing_by_code(OWNER_A, "123456").await.unwrap().unwrap(); + let found = repo.get_pairing(OWNER_A, "pair-1").await.unwrap().unwrap(); assert_eq!(found.status, "approved"); + assert_eq!(found.approved_channel_user_id.as_deref(), Some("usr-1")); } #[tokio::test] async fn update_pairing_status_not_found() { let (repo, _db) = setup().await; let err = repo - .update_pairing_status(OWNER_A, "000000", "approved") + .update_pairing_status(OWNER_A, "nope", "approved", None) .await .unwrap_err(); assert!(matches!(err, DbError::NotFound(_))); } + #[tokio::test] + async fn expire_pending_pairings_for_user_targets_one_user() { + let (repo, _db) = setup().await; + seed_connection(&repo, OWNER_A).await; + repo.create_pairing(OWNER_A, &sample_pairing()).await.unwrap(); + + let other = ChannelPairingRequestRow { + id: "pair-2".into(), + platform_user_id: "tg_100".into(), + code_hash: "hash-654321".into(), + ..sample_pairing() + }; + repo.create_pairing(OWNER_A, &other).await.unwrap(); + + let expired = repo + .expire_pending_pairings_for_user(OWNER_A, "tg-1", "tg_99") + .await + .unwrap(); + assert_eq!(expired, 1); + + assert_eq!( + repo.get_pairing(OWNER_A, "pair-1").await.unwrap().unwrap().status, + "expired" + ); + assert_eq!( + repo.get_pairing(OWNER_A, "pair-2").await.unwrap().unwrap().status, + "pending" + ); + } + #[tokio::test] async fn cleanup_expired_pairings_marks_expired() { let (repo, _db) = setup().await; + seed_connection(&repo, OWNER_A).await; let now = aionui_common::now_ms(); // Create an already-expired pairing. - let expired = PairingCodeRow { - code: "111111".into(), + let expired = ChannelPairingRequestRow { + id: "pair-expired".into(), + platform_user_id: "tg_expired".into(), + code_hash: "hash-111111".into(), expires_at: now - 1000, ..sample_pairing() }; repo.create_pairing(OWNER_A, &expired).await.unwrap(); // Create a still-valid pairing. - let valid = PairingCodeRow { - code: "222222".into(), + let valid = ChannelPairingRequestRow { + id: "pair-valid".into(), + platform_user_id: "tg_valid".into(), + code_hash: "hash-222222".into(), expires_at: now + 600_000, ..sample_pairing() }; @@ -1409,21 +1752,23 @@ mod tests { let cleaned = repo.cleanup_expired_pairings(OWNER_A, now).await.unwrap(); assert_eq!(cleaned, 1); - let found_expired = repo.get_pairing_by_code(OWNER_A, "111111").await.unwrap().unwrap(); + let found_expired = repo.get_pairing(OWNER_A, "pair-expired").await.unwrap().unwrap(); assert_eq!(found_expired.status, "expired"); - let found_valid = repo.get_pairing_by_code(OWNER_A, "222222").await.unwrap().unwrap(); + let found_valid = repo.get_pairing(OWNER_A, "pair-valid").await.unwrap().unwrap(); assert_eq!(found_valid.status, "pending"); } #[tokio::test] async fn cleanup_expired_pairings_skips_non_pending() { let (repo, _db) = setup().await; + seed_connection(&repo, OWNER_A).await; let now = aionui_common::now_ms(); // Create an expired pairing that is already approved. - let approved = PairingCodeRow { - code: "333333".into(), + let approved = ChannelPairingRequestRow { + id: "pair-approved".into(), + code_hash: "hash-333333".into(), expires_at: now - 1000, status: "approved".into(), ..sample_pairing() @@ -1433,7 +1778,7 @@ mod tests { let cleaned = repo.cleanup_expired_pairings(OWNER_A, now).await.unwrap(); assert_eq!(cleaned, 0); - let found = repo.get_pairing_by_code(OWNER_A, "333333").await.unwrap().unwrap(); + let found = repo.get_pairing(OWNER_A, "pair-approved").await.unwrap().unwrap(); assert_eq!(found.status, "approved"); } } diff --git a/crates/aionui-db/src/repository/sqlite_client_preference.rs b/crates/aionui-db/src/repository/sqlite_client_preference.rs index cfeb8cfaa..9b143c1f9 100644 --- a/crates/aionui-db/src/repository/sqlite_client_preference.rs +++ b/crates/aionui-db/src/repository/sqlite_client_preference.rs @@ -19,11 +19,12 @@ impl SqliteClientPreferenceRepository { #[async_trait::async_trait] impl IClientPreferenceRepository for SqliteClientPreferenceRepository { async fn get_all(&self, user_id: &str) -> Result, DbError> { - let rows = - sqlx::query_as::<_, ClientPreference>("SELECT * FROM client_preferences WHERE user_id = ? ORDER BY key") - .bind(user_id) - .fetch_all(&self.pool) - .await?; + let rows = sqlx::query_as::<_, ClientPreference>( + "SELECT * FROM client_preferences WHERE scope = 'account' AND user_id = ? ORDER BY key", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; Ok(rows) } @@ -33,11 +34,9 @@ impl IClientPreferenceRepository for SqliteClientPreferenceRepository { return Ok(vec![]); } - // Build dynamic IN clause with positional placeholders - let placeholders: Vec<&str> = keys.iter().map(|_| "?").collect(); let sql = format!( - "SELECT * FROM client_preferences WHERE user_id = ? AND key IN ({}) ORDER BY key", - placeholders.join(", ") + "SELECT * FROM client_preferences WHERE scope = 'account' AND user_id = ? AND key IN ({}) ORDER BY key", + key_placeholders(keys) ); let mut query = sqlx::query_as::<_, ClientPreference>(&sql).bind(user_id); @@ -60,10 +59,12 @@ impl IClientPreferenceRepository for SqliteClientPreferenceRepository { let mut tx = self.pool.begin().await?; for (key, value) in entries { + // Conflict target matches the partial unique index + // `idx_client_preferences_account_key`. sqlx::query( - "INSERT INTO client_preferences (user_id, key, value, updated_at) \ - VALUES (?, ?, ?, ?) \ - ON CONFLICT(user_id, key) DO UPDATE SET \ + "INSERT INTO client_preferences (scope, user_id, key, value, updated_at) \ + VALUES ('account', ?, ?, ?, ?) \ + ON CONFLICT(user_id, key) WHERE scope = 'account' DO UPDATE SET \ value = excluded.value, \ updated_at = excluded.updated_at", ) @@ -84,10 +85,9 @@ impl IClientPreferenceRepository for SqliteClientPreferenceRepository { return Ok(()); } - let placeholders: Vec<&str> = keys.iter().map(|_| "?").collect(); let sql = format!( - "DELETE FROM client_preferences WHERE user_id = ? AND key IN ({})", - placeholders.join(", ") + "DELETE FROM client_preferences WHERE scope = 'account' AND user_id = ? AND key IN ({})", + key_placeholders(keys) ); let mut query = sqlx::query(&sql).bind(user_id); @@ -98,6 +98,88 @@ impl IClientPreferenceRepository for SqliteClientPreferenceRepository { query.execute(&self.pool).await?; Ok(()) } + + async fn get_all_device(&self) -> Result, DbError> { + let rows = sqlx::query_as::<_, ClientPreference>( + "SELECT * FROM client_preferences WHERE scope = 'device' ORDER BY key", + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows) + } + + async fn get_device_by_keys(&self, keys: &[&str]) -> Result, DbError> { + if keys.is_empty() { + return Ok(vec![]); + } + + let sql = format!( + "SELECT * FROM client_preferences WHERE scope = 'device' AND key IN ({}) ORDER BY key", + key_placeholders(keys) + ); + + let mut query = sqlx::query_as::<_, ClientPreference>(&sql); + for key in keys { + query = query.bind(*key); + } + + let rows = query.fetch_all(&self.pool).await?; + Ok(rows) + } + + async fn upsert_device_batch(&self, entries: &[(&str, &str)]) -> Result<(), DbError> { + if entries.is_empty() { + return Ok(()); + } + + let now = aionui_common::now_ms(); + let mut tx = self.pool.begin().await?; + + for (key, value) in entries { + // Conflict target matches the partial unique index + // `idx_client_preferences_device_key`. + sqlx::query( + "INSERT INTO client_preferences (scope, user_id, key, value, updated_at) \ + VALUES ('device', NULL, ?, ?, ?) \ + ON CONFLICT(key) WHERE scope = 'device' DO UPDATE SET \ + value = excluded.value, \ + updated_at = excluded.updated_at", + ) + .bind(*key) + .bind(*value) + .bind(now) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) + } + + async fn delete_device_keys(&self, keys: &[&str]) -> Result<(), DbError> { + if keys.is_empty() { + return Ok(()); + } + + let sql = format!( + "DELETE FROM client_preferences WHERE scope = 'device' AND key IN ({})", + key_placeholders(keys) + ); + + let mut query = sqlx::query(&sql); + for key in keys { + query = query.bind(*key); + } + + query.execute(&self.pool).await?; + Ok(()) + } +} + +/// Comma-separated positional placeholders for a dynamic `IN (...)` clause. +fn key_placeholders(keys: &[&str]) -> String { + keys.iter().map(|_| "?").collect::>().join(", ") } #[cfg(test)] @@ -133,16 +215,20 @@ mod tests { #[tokio::test] async fn upsert_and_get_all() { let (repo, _db) = setup().await; - repo.upsert_batch(USER_A, &[("theme", "\"dark\""), ("pet.size", "360")]) + repo.upsert_batch(USER_A, &[("theme", "\"dark\""), ("appearance.size", "360")]) .await .unwrap(); let prefs = repo.get_all(USER_A).await.unwrap(); assert_eq!(prefs.len(), 2); - assert_eq!(prefs[0].key, "pet.size"); + assert_eq!(prefs[0].key, "appearance.size"); assert_eq!(prefs[0].value, "360"); assert_eq!(prefs[1].key, "theme"); assert_eq!(prefs[1].value, "\"dark\""); + for pref in &prefs { + assert_eq!(pref.scope, "account"); + assert_eq!(pref.user_id.as_deref(), Some(USER_A)); + } } #[tokio::test] @@ -230,4 +316,96 @@ mod tests { assert!(repo.get_by_keys(USER_B, &["theme"]).await.unwrap().is_empty()); assert_eq!(repo.get_by_keys(USER_A, &["theme"]).await.unwrap().len(), 1); } + + // -- device scope -- + + #[tokio::test] + async fn device_rows_have_no_owner_and_are_machine_wide() { + let (repo, _db) = setup().await; + repo.upsert_device_batch(&[("keepAwake", "true")]).await.unwrap(); + + let rows = repo.get_device_by_keys(&["keepAwake"]).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].scope, "device"); + assert_eq!(rows[0].user_id, None); + assert_eq!(rows[0].value, "true"); + } + + #[tokio::test] + async fn device_upsert_overwrites_the_single_machine_value() { + let (repo, _db) = setup().await; + repo.upsert_device_batch(&[("keepAwake", "true")]).await.unwrap(); + repo.upsert_device_batch(&[("keepAwake", "false")]).await.unwrap(); + + let rows = repo.get_all_device().await.unwrap(); + assert_eq!(rows.len(), 1, "device scope keeps exactly one row per key"); + assert_eq!(rows[0].value, "false"); + } + + #[tokio::test] + async fn device_and_account_scopes_are_separate_stores() { + let (repo, _db) = setup().await; + repo.upsert_device_batch(&[("keepAwake", "true")]).await.unwrap(); + repo.upsert_batch(USER_A, &[("theme", "\"dark\"")]).await.unwrap(); + + // Account reads never see device rows… + let account = repo.get_all(USER_A).await.unwrap(); + assert_eq!(account.len(), 1); + assert_eq!(account[0].key, "theme"); + assert!(repo.get_by_keys(USER_A, &["keepAwake"]).await.unwrap().is_empty()); + + // …and device reads never see account rows. + let device = repo.get_all_device().await.unwrap(); + assert_eq!(device.len(), 1); + assert_eq!(device[0].key, "keepAwake"); + assert!(repo.get_device_by_keys(&["theme"]).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn device_delete_removes_the_machine_value_only() { + let (repo, _db) = setup().await; + repo.upsert_device_batch(&[("keepAwake", "true"), ("system.closeToTray", "true")]) + .await + .unwrap(); + repo.upsert_batch(USER_A, &[("keepAwake", "\"account-copy\"")]) + .await + .unwrap(); + + repo.delete_device_keys(&["keepAwake"]).await.unwrap(); + + let device = repo.get_all_device().await.unwrap(); + assert_eq!(device.len(), 1); + assert_eq!(device[0].key, "system.closeToTray"); + // The (hypothetical) account row with the same key is untouched. + assert_eq!(repo.get_by_keys(USER_A, &["keepAwake"]).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn device_delete_nonexistent_is_noop() { + let (repo, _db) = setup().await; + repo.delete_device_keys(&["ghost"]).await.unwrap(); + assert!(repo.get_all_device().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn device_empty_inputs_are_noops() { + let (repo, _db) = setup().await; + repo.upsert_device_batch(&[]).await.unwrap(); + assert!(repo.get_all_device().await.unwrap().is_empty()); + assert!(repo.get_device_by_keys(&[]).await.unwrap().is_empty()); + + repo.upsert_device_batch(&[("keepAwake", "true")]).await.unwrap(); + repo.delete_device_keys(&[]).await.unwrap(); + assert_eq!(repo.get_all_device().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn device_get_by_keys_omits_missing_keys() { + let (repo, _db) = setup().await; + repo.upsert_device_batch(&[("a", "1"), ("c", "3")]).await.unwrap(); + + let rows = repo.get_device_by_keys(&["a", "b", "c"]).await.unwrap(); + let keys: Vec<&str> = rows.iter().map(|row| row.key.as_str()).collect(); + assert_eq!(keys, vec!["a", "c"]); + } } diff --git a/crates/aionui-db/src/repository/sqlite_user.rs b/crates/aionui-db/src/repository/sqlite_user.rs index 108349e67..7f16bb0db 100644 --- a/crates/aionui-db/src/repository/sqlite_user.rs +++ b/crates/aionui-db/src/repository/sqlite_user.rs @@ -218,6 +218,13 @@ impl IUserRepository for SqliteUserRepository { // bindings) or reference another root's `user_id` (project explorer). // The exhaustiveness of this convention is enforced by the // adoption-coverage classification test in aionui-db/tests. + // Re-owning happens table by table, so a composite foreign key that + // spans two adopted tables (channel_users → channel_connections on + // (owner_user_id, id)) is transiently violated between the two + // UPDATEs even though the committed state is consistent. Defer FK + // checks to COMMIT; SQLite resets this at the end of the transaction. + sqlx::query("PRAGMA defer_foreign_keys = ON").execute(&mut *tx).await?; + let mut moved: u64 = 0; for owner_column in ["user_id", "owner_user_id"] { let tables: Vec<(String,)> = sqlx::query_as( diff --git a/crates/aionui-db/tests/adoption_coverage.rs b/crates/aionui-db/tests/adoption_coverage.rs index cf4bb10f9..547ed65a5 100644 --- a/crates/aionui-db/tests/adoption_coverage.rs +++ b/crates/aionui-db/tests/adoption_coverage.rs @@ -44,14 +44,14 @@ const GLOBAL_TABLES: &[(&str, &str)] = &[ /// Without this, "has a `user_id` column" would classify the table as an /// ownership root by accident and the gate would pass without a conscious /// decision. Each entry: (table, the non-Core table its `user_id` points at). -const NON_CORE_USER_ID_TABLES: &[(&str, &str)] = &[ - // assistant_sessions.user_id -> assistant_users.id (a channel/platform - // user). Core ownership flows through assistant_users.owner_user_id, which - // adoption re-owns; adoption's UPDATE on this `user_id` never matches - // 'system_default_user' (it holds assistant_users UUIDs), so it is a - // harmless no-op. - ("assistant_sessions", "assistant_users"), -]; +/// +/// Currently empty: the only entry was `assistant_sessions`, whose misleading +/// `user_id` the channel refactor renamed to `channel_user_id` on +/// `channel_conversation_bindings`. That table now carries its own +/// `owner_user_id` and classifies as a plain ownership root. The mechanism is +/// kept so a future table with the same shape needs a conscious declaration +/// rather than silently passing the gate. +const NON_CORE_USER_ID_TABLES: &[(&str, &str)] = &[]; /// Identity / infrastructure tables outside the ownership model. const INFRA_TABLES: &[&str] = &["users", "_sqlx_migrations"]; @@ -220,10 +220,21 @@ async fn first_external_user_adopts_owner_user_id_tables_too() { let (project_id, pe_id) = seed_default_user_project(pool).await; // Channel binding owned by the pre-upgrade local user (`owner_user_id` - // table with a coexisting platform identity column). + // table with a coexisting platform identity column). The binding hangs + // off a connection, itself an owner_user_id table, so both must be + // adopted together for the composite FK to stay satisfied. + sqlx::query( + "INSERT INTO channel_connections + (id, owner_user_id, plugin_key, name, enabled, config, created_at, updated_at) + VALUES ('conn-legacy', 'system_default_user', 'telegram', 'TG', 0, '{}', 1, 1)", + ) + .execute(pool) + .await + .unwrap(); sqlx::query( - "INSERT INTO assistant_users (id, platform_user_id, platform_type, display_name, authorized_at, owner_user_id) - VALUES ('au-legacy', 'tg-123', 'telegram', 'TG User', 1, 'system_default_user')", + "INSERT INTO channel_users + (id, owner_user_id, connection_id, external_user_id, display_name, status, authorized_at) + VALUES ('au-legacy', 'system_default_user', 'conn-legacy', 'tg-123', 'TG User', 'active', 1)", ) .execute(pool) .await @@ -247,12 +258,21 @@ async fn first_external_user_adopts_owner_user_id_tables_too() { "conversation + project + explorer entry + channel binding must move, moved={moved}" ); - let binding_owner: String = sqlx::query_scalar("SELECT owner_user_id FROM assistant_users WHERE id = 'au-legacy'") + let binding_owner: String = sqlx::query_scalar("SELECT owner_user_id FROM channel_users WHERE id = 'au-legacy'") .fetch_one(pool) .await .unwrap(); assert_eq!(binding_owner, user.id, "channel platform binding must be adopted"); + // Its connection must move with it, or the composite FK + // (owner_user_id, connection_id) would dangle. + let connection_owner: String = + sqlx::query_scalar("SELECT owner_user_id FROM channel_connections WHERE id = 'conn-legacy'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(connection_owner, user.id, "channel connection must be adopted too"); + let conv_owner: String = sqlx::query_scalar("SELECT user_id FROM conversations WHERE id = 'conv-legacy'") .fetch_one(pool) .await @@ -278,6 +298,66 @@ async fn first_external_user_adopts_owner_user_id_tables_too() { db.close().await; } +/// `client_preferences` is an ownership root by its `user_id` column, but that +/// column is nullable since migration 031: device-scope rows describe the +/// machine and carry no owner. Adoption must move the account rows and leave +/// the machine rows exactly where they are — the same rule the ownership loop +/// already applies to NULL-owner catalog rows. +#[tokio::test] +async fn adoption_moves_account_preferences_and_leaves_device_preferences_machine_global() { + let db = init_database_memory().await.unwrap(); + let pool = db.pool(); + let repo = SqliteUserRepository::new(pool.clone()); + + sqlx::query( + "INSERT INTO client_preferences (scope, user_id, key, value, updated_at) + VALUES ('account', 'system_default_user', 'theme', '\"dark\"', 1)", + ) + .execute(pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO client_preferences (scope, user_id, key, value, updated_at) + VALUES ('device', NULL, 'keepAwake', 'true', 1)", + ) + .execute(pool) + .await + .unwrap(); + + let user = repo + .ensure_external_user( + UserType::Aionpro, + "ext-prefs", + ExternalUserProjection { + username: Some("Pro".into()), + email: None, + avatar_path: None, + }, + ) + .await + .unwrap(); + repo.adopt_system_default_data(&user.id).await.unwrap(); + + let theme_owner: Option = sqlx::query_scalar("SELECT user_id FROM client_preferences WHERE key = 'theme'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!( + theme_owner.as_deref(), + Some(user.id.as_str()), + "account pref is adopted" + ); + + let keep_awake_owner: Option = + sqlx::query_scalar("SELECT user_id FROM client_preferences WHERE key = 'keepAwake'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(keep_awake_owner, None, "device pref must stay owner-less"); + + db.close().await; +} + #[tokio::test] async fn adoption_window_closes_with_second_external_user() { let db = init_database_memory().await.unwrap(); diff --git a/crates/aionui-db/tests/channel_connections_migration.rs b/crates/aionui-db/tests/channel_connections_migration.rs new file mode 100644 index 000000000..a7cf4c7d2 --- /dev/null +++ b/crates/aionui-db/tests/channel_connections_migration.rs @@ -0,0 +1,271 @@ +//! Migration 030: assistant_plugins → channel_connections (connection entity) +//! and assistant_sessions → channel_conversation_bindings. +//! +//! Verifies the segment-1 backfill: legacy platform-type ids become +//! `plugin_key`, connection ids are freshly generated, config/state columns +//! are preserved, and the phase-1 single-instance index holds. Segment 3 is +//! covered below: bindings inherit owner/connection from their channel user, +//! `chat_id` survives as `external_chat_id`, agent config columns are gone, +//! and cross-account conversation bindings are unrepresentable. + +use std::borrow::Cow; +use std::path::Path; + +use sqlx::Row; +use sqlx::migrate::Migrator; +use sqlx::sqlite::SqlitePoolOptions; + +async fn run_migrations_through(pool: &sqlx::SqlitePool, max_version: i64) { + sqlx::query("PRAGMA foreign_keys = OFF").execute(pool).await.unwrap(); + let full = Migrator::new(Path::new("migrations")).await.unwrap(); + let migrations = full + .migrations + .iter() + .filter(|migration| migration.version <= max_version) + .cloned() + .collect::>(); + let migrator = Migrator { + migrations: Cow::Owned(migrations), + ignore_missing: false, + locking: true, + no_tx: false, + }; + migrator.run(pool).await.unwrap(); +} + +async fn run_migration(pool: &sqlx::SqlitePool, version: i64) { + let full = Migrator::new(Path::new("migrations")).await.unwrap(); + let migrations = full + .migrations + .iter() + .filter(|migration| migration.version == version) + .cloned() + .collect::>(); + let migrator = Migrator { + migrations: Cow::Owned(migrations), + ignore_missing: true, + locking: true, + no_tx: false, + }; + migrator.run(pool).await.unwrap(); +} + +#[tokio::test] +async fn migration_030_rebuilds_plugins_as_connections() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + run_migrations_through(&pool, 30).await; + + // Legacy rows: id IS the platform type (one per owner+platform). + sqlx::query( + "INSERT INTO assistant_plugins ( + id, owner_user_id, type, name, enabled, config, status, + last_connected, created_at, updated_at + ) VALUES + ('weixin', 'system_default_user', 'weixin', 'WeChat', 1, 'enc-config', 'running', 111, 1, 2), + ('telegram', 'system_default_user', 'telegram', 'TG Bot', 0, 'enc-tg', NULL, NULL, 3, 4)", + ) + .execute(&pool) + .await + .unwrap(); + + run_migration(&pool, 31).await; + + // Old table is gone; new table holds one connection per legacy row. + let old_table: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'assistant_plugins'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(old_table, 0); + + let rows = sqlx::query( + "SELECT id, owner_user_id, plugin_key, name, enabled, config, status, last_connected \ + FROM channel_connections ORDER BY created_at ASC", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + + let weixin = &rows[0]; + assert_eq!(weixin.get::("plugin_key"), "weixin"); + // The connection id is generated and no longer the platform type. + let conn_id = weixin.get::("id"); + assert!(conn_id.starts_with("conn_"), "unexpected id: {conn_id}"); + assert_ne!(conn_id, "weixin"); + assert_eq!(weixin.get::("owner_user_id"), "system_default_user"); + assert_eq!(weixin.get::("name"), "WeChat"); + assert!(weixin.get::("enabled")); + assert_eq!(weixin.get::("config"), "enc-config"); + assert_eq!(weixin.get::, _>("status").as_deref(), Some("running")); + assert_eq!(weixin.get::, _>("last_connected"), Some(111)); + + let telegram = &rows[1]; + assert_eq!(telegram.get::("plugin_key"), "telegram"); + assert_ne!(telegram.get::("id"), conn_id); + + // Phase-1 single-instance guard is present. + let dup = sqlx::query( + "INSERT INTO channel_connections ( + id, owner_user_id, plugin_key, name, enabled, config, created_at, updated_at + ) VALUES ('conn_dup', 'system_default_user', 'weixin', 'Dup', 0, '', 5, 5)", + ) + .execute(&pool) + .await; + let err = dup.unwrap_err().to_string(); + assert!(err.contains("UNIQUE"), "unexpected error: {err}"); +} + +/// Segment 3: `assistant_sessions` becomes `channel_conversation_bindings`. +#[tokio::test] +async fn migration_030_rebuilds_sessions_as_conversation_bindings() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + run_migrations_through(&pool, 30).await; + + for uid in ["system_default_user", "other_core_user"] { + sqlx::query( + "INSERT OR IGNORE INTO users \ + (id, user_type, username, password_hash, status, session_generation, created_at, updated_at) \ + VALUES (?, 'local', ?, 'hash', 'active', 0, 1, 1)", + ) + .bind(uid) + .bind(uid) + .execute(&pool) + .await + .unwrap(); + } + + sqlx::query( + "INSERT INTO conversations (id, user_id, name, type, extra, status, created_at, updated_at) VALUES \ + ('conv-own', 'system_default_user', 'c', 'gemini', '{}', 'pending', 1, 1), \ + ('conv-other', 'other_core_user', 'c', 'gemini', '{}', 'pending', 1, 1)", + ) + .execute(&pool) + .await + .unwrap(); + + // Legacy pre-030 rows: plugin id IS the platform type, sessions carry + // agent_type/workspace and hang off the user by a bare FK. + sqlx::query( + "INSERT INTO assistant_plugins ( + id, owner_user_id, type, name, enabled, config, status, + last_connected, created_at, updated_at + ) VALUES ('telegram', 'system_default_user', 'telegram', 'TG Bot', 1, 'enc-tg', NULL, NULL, 1, 2)", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO assistant_users ( + id, owner_user_id, platform_user_id, platform_type, display_name, + authorized_at, last_active, session_id + ) VALUES ('usr-1', 'system_default_user', 'tg_1', 'telegram', 'Alice', 10, 11, NULL)", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO assistant_sessions ( + id, user_id, agent_type, conversation_id, workspace, chat_id, + created_at, last_activity + ) VALUES ('sess-1', 'usr-1', 'gemini', 'conv-own', '/tmp/ws', 'chat-abc', 20, 21)", + ) + .execute(&pool) + .await + .unwrap(); + + run_migration(&pool, 31).await; + + // The legacy table is gone, replaced by the binding table. + let old_table: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'assistant_sessions'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(old_table, 0); + + let connection_id: String = sqlx::query_scalar("SELECT id FROM channel_connections WHERE plugin_key = 'telegram'") + .fetch_one(&pool) + .await + .unwrap(); + + let row = sqlx::query( + "SELECT id, owner_user_id, connection_id, channel_user_id, external_chat_id, \ + conversation_id, created_at, last_active_at \ + FROM channel_conversation_bindings", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(row.len(), 1); + let binding = &row[0]; + assert_eq!(binding.get::("id"), "sess-1"); + // Owner and connection are derived through the channel user, not stored + // on the legacy session row. + assert_eq!(binding.get::("owner_user_id"), "system_default_user"); + assert_eq!(binding.get::("connection_id"), connection_id); + assert_eq!(binding.get::("channel_user_id"), "usr-1"); + // Renamed columns keep their legacy values. + assert_eq!( + binding.get::, _>("external_chat_id").as_deref(), + Some("chat-abc") + ); + assert_eq!( + binding.get::, _>("conversation_id").as_deref(), + Some("conv-own") + ); + assert_eq!(binding.get::("created_at"), 20); + assert_eq!(binding.get::("last_active_at"), 21); + + // agent_type / workspace are gone from the schema, not merely unused. + let columns: Vec = sqlx::query("PRAGMA table_info(channel_conversation_bindings)") + .fetch_all(&pool) + .await + .unwrap() + .iter() + .map(|r| r.get::("name")) + .collect(); + assert!( + !columns.iter().any(|c| c == "agent_type" || c == "workspace"), + "agent config columns must not survive the rebuild: {columns:?}" + ); + + // Cross-account guard: binding another Core user's conversation aborts. + let err = sqlx::query( + "INSERT INTO channel_conversation_bindings ( + id, owner_user_id, connection_id, channel_user_id, external_chat_id, + conversation_id, created_at, last_active_at + ) VALUES ('sess-x', 'system_default_user', ?, 'usr-1', 'chat-x', 'conv-other', 30, 30)", + ) + .bind(&connection_id) + .execute(&pool) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("CROSS_ACCOUNT_REFERENCE"), + "expected cross-account trigger abort, got: {err}" + ); + + // Same guard on UPDATE. + let update_err = + sqlx::query("UPDATE channel_conversation_bindings SET conversation_id = 'conv-other' WHERE id = 'sess-1'") + .execute(&pool) + .await + .unwrap_err() + .to_string(); + assert!( + update_err.contains("CROSS_ACCOUNT_REFERENCE"), + "expected cross-account trigger abort on update, got: {update_err}" + ); +} diff --git a/crates/aionui-db/tests/channel_repository.rs b/crates/aionui-db/tests/channel_repository.rs index 3330178fb..d5b5ca64d 100644 --- a/crates/aionui-db/tests/channel_repository.rs +++ b/crates/aionui-db/tests/channel_repository.rs @@ -6,10 +6,16 @@ use std::sync::Arc; -use aionui_db::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow}; -use aionui_db::{DbError, IChannelRepository, SqliteChannelRepository, UpdatePluginStatusParams, init_database_memory}; +use aionui_db::models::{ + ChannelConnectionRow, ChannelConversationBindingRow, ChannelPairingRequestRow, ChannelUserRow, +}; +use aionui_db::{ + DbError, IChannelRepository, SqliteChannelRepository, UpdateConnectionStatusParams, init_database_memory, +}; const OWNER_ID: &str = "system_default_user"; +/// Connection every channel user / pairing request in this file attaches to. +const TG_CONN: &str = "conn-telegram"; async fn repo() -> (Arc, aionui_db::Database) { let db = init_database_memory().await.unwrap(); @@ -17,12 +23,19 @@ async fn repo() -> (Arc, aionui_db::Database) { (r as Arc, db) } -fn make_plugin(id: &str, plugin_type: &str) -> ChannelPluginRow { +/// Seeds the FK-parent connection channel users and pairings hang off. +async fn seed_connection(repo: &Arc) { + repo.upsert_connection(OWNER_ID, &make_plugin(TG_CONN, "telegram")) + .await + .unwrap(); +} + +fn make_plugin(id: &str, plugin_type: &str) -> ChannelConnectionRow { let now = aionui_common::now_ms(); - ChannelPluginRow { + ChannelConnectionRow { id: id.into(), owner_user_id: OWNER_ID.into(), - r#type: plugin_type.into(), + plugin_key: plugin_type.into(), name: format!("{plugin_type} bot"), enabled: false, config: r#"{"credentials":{}}"#.into(), @@ -33,45 +46,56 @@ fn make_plugin(id: &str, plugin_type: &str) -> ChannelPluginRow { } } -fn make_user(id: &str, platform_uid: &str, platform: &str) -> AssistantUserRow { +fn make_user(id: &str, platform_uid: &str, platform: &str) -> ChannelUserRow { let now = aionui_common::now_ms(); - AssistantUserRow { + ChannelUserRow { id: id.into(), owner_user_id: OWNER_ID.into(), + connection_id: TG_CONN.into(), platform_user_id: platform_uid.into(), + // Derived from the connection on read; ignored on write. platform_type: platform.into(), display_name: Some(format!("User {id}")), + status: "active".into(), + revoked_at: None, authorized_at: now, last_active: None, - session_id: None, } } -fn make_session(id: &str, user_id: &str, chat_id: &str) -> AssistantSessionRow { +/// `owner_user_id`/`connection_id` are left empty: the repository derives +/// both from the active `channel_users` row rather than trusting the caller. +fn make_session(id: &str, user_id: &str, chat_id: &str) -> ChannelConversationBindingRow { let now = aionui_common::now_ms(); - AssistantSessionRow { + ChannelConversationBindingRow { id: id.into(), + owner_user_id: String::new(), + connection_id: String::new(), user_id: user_id.into(), - agent_type: "gemini".into(), - conversation_id: None, - workspace: None, chat_id: Some(chat_id.into()), + conversation_id: None, created_at: now, last_activity: now, } } -fn make_pairing(code: &str, platform_uid: &str, expires_offset_ms: i64) -> PairingCodeRow { +/// Builds a pairing request. `code` is only ever hashed — the plaintext is +/// never persisted, so the row carries `code_hash` derived from it here. +fn make_pairing(id: &str, code: &str, platform_uid: &str, expires_offset_ms: i64) -> ChannelPairingRequestRow { let now = aionui_common::now_ms(); - PairingCodeRow { - code: code.into(), + ChannelPairingRequestRow { + id: id.into(), owner_user_id: OWNER_ID.into(), + connection_id: TG_CONN.into(), platform_user_id: platform_uid.into(), + // Derived from the connection on read; ignored on write. platform_type: "telegram".into(), display_name: Some("Tester".into()), + code_hash: format!("hash-{code}"), + status: "pending".into(), requested_at: now, expires_at: now + expires_offset_ms, - status: "pending".into(), + approved_channel_user_id: None, } } @@ -82,22 +106,22 @@ async fn plugin_full_lifecycle() { let (repo, _db) = repo().await; // Empty initially. - assert!(repo.get_all_plugins(OWNER_ID).await.unwrap().is_empty()); + assert!(repo.get_all_connections(OWNER_ID).await.unwrap().is_empty()); // Create two plugins. - repo.upsert_plugin(OWNER_ID, &make_plugin("tg-1", "telegram")) + repo.upsert_connection(OWNER_ID, &make_plugin("tg-1", "telegram")) .await .unwrap(); - repo.upsert_plugin(OWNER_ID, &make_plugin("lark-1", "lark")) + repo.upsert_connection(OWNER_ID, &make_plugin("lark-1", "lark")) .await .unwrap(); - assert_eq!(repo.get_all_plugins(OWNER_ID).await.unwrap().len(), 2); + assert_eq!(repo.get_all_connections(OWNER_ID).await.unwrap().len(), 2); // Update status. - repo.update_plugin_status( + repo.update_connection_status( OWNER_ID, "tg-1", - &UpdatePluginStatusParams { + &UpdateConnectionStatusParams { status: Some("running".into()), enabled: Some(true), ..Default::default() @@ -106,13 +130,43 @@ async fn plugin_full_lifecycle() { .await .unwrap(); - let tg = repo.get_plugin(OWNER_ID, "tg-1").await.unwrap().unwrap(); + let tg = repo.get_connection(OWNER_ID, "tg-1").await.unwrap().unwrap(); assert!(tg.enabled); assert_eq!(tg.status.as_deref(), Some("running")); // Delete one. - repo.delete_plugin(OWNER_ID, "lark-1").await.unwrap(); - assert_eq!(repo.get_all_plugins(OWNER_ID).await.unwrap().len(), 1); + repo.delete_connection(OWNER_ID, "lark-1").await.unwrap(); + assert_eq!(repo.get_all_connections(OWNER_ID).await.unwrap().len(), 1); +} + +#[tokio::test] +async fn phase1_single_connection_per_plugin_key_enforced() { + let (repo, _db) = repo().await; + repo.upsert_connection(OWNER_ID, &make_plugin("conn-a", "telegram")) + .await + .unwrap(); + + // A second connection for the same (owner, plugin_key) violates the + // phase-1 single-instance unique index. + let err = repo + .upsert_connection(OWNER_ID, &make_plugin("conn-b", "telegram")) + .await + .unwrap_err(); + assert!(err.to_string().contains("UNIQUE"), "unexpected error: {err}"); + + // Plugin-key lookup resolves the single instance. + let found = repo + .get_connection_by_plugin_key(OWNER_ID, "telegram") + .await + .unwrap() + .unwrap(); + assert_eq!(found.id, "conn-a"); + assert!( + repo.get_connection_by_plugin_key(OWNER_ID, "lark") + .await + .unwrap() + .is_none() + ); } // ── DC-3: Same platform user uniqueness constraint ─────────────────── @@ -120,6 +174,7 @@ async fn plugin_full_lifecycle() { #[tokio::test] async fn dc3_duplicate_platform_user_rejected() { let (repo, _db) = repo().await; + seed_connection(&repo).await; repo.create_user(OWNER_ID, &make_user("u1", "tg_100", "telegram")) .await .unwrap(); @@ -130,11 +185,16 @@ async fn dc3_duplicate_platform_user_rejected() { assert!(matches!(err, DbError::Conflict(_))); } -// ── DC-1: Revoke user cascade deletes sessions ─────────────────────── +// ── DC-1: Revoking a user removes their sessions ───────────────────── +// +// Revocation is a soft delete, so the sessions no longer disappear via FK +// cascade — `revoke_user` deletes them explicitly. The authorization row +// itself is retained for audit. #[tokio::test] -async fn dc1_delete_user_cascades_sessions() { - let (repo, _db) = repo().await; +async fn dc1_revoke_user_removes_sessions() { + let (repo, db) = repo().await; + seed_connection(&repo).await; repo.create_user(OWNER_ID, &make_user("u1", "tg_1", "telegram")) .await .unwrap(); @@ -148,9 +208,43 @@ async fn dc1_delete_user_cascades_sessions() { .unwrap(); assert_eq!(repo.get_all_sessions(OWNER_ID).await.unwrap().len(), 2); - // Delete user → sessions cascade. - repo.delete_user(OWNER_ID, "u1").await.unwrap(); + repo.revoke_user(OWNER_ID, "u1").await.unwrap(); + assert!(repo.get_all_sessions(OWNER_ID).await.unwrap().is_empty()); + let remaining: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM channel_conversation_bindings WHERE channel_user_id = 'u1'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(remaining, 0); + + // Revocation is also forward-looking: a revoked user cannot open a new + // binding, so message routing cannot resurrect itself. + let err = repo + .get_or_create_session(OWNER_ID, "u1", "chat-c", &make_session("s3", "u1", "chat-c")) + .await; + assert!( + matches!(err, Err(DbError::NotFound(_))), + "revoked user must not create a binding, got {err:?}" + ); + assert!(repo.get_all_sessions(OWNER_ID).await.unwrap().is_empty()); + + // The user is gone from the active surface … + assert!(repo.get_all_users(OWNER_ID).await.unwrap().is_empty()); + assert!( + repo.get_user_by_platform(OWNER_ID, "tg_1", "telegram") + .await + .unwrap() + .is_none() + ); + // … but the audit row survives. + let (status, revoked_at): (String, Option) = + sqlx::query_as("SELECT status, revoked_at FROM channel_users WHERE id = 'u1'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(status, "revoked"); + assert!(revoked_at.is_some()); } // ── Cross-account guard: a channel session may not bind another Core user's @@ -161,6 +255,7 @@ async fn session_rejects_conversation_owned_by_another_core_user() { let (repo, db) = repo().await; let pool = db.pool(); + seed_connection(&repo).await; repo.create_user(OWNER_ID, &make_user("u1", "tg_1", "telegram")) .await .unwrap(); @@ -225,6 +320,7 @@ async fn session_rejects_conversation_owned_by_another_core_user() { #[tokio::test] async fn pc1_same_user_different_chat_ids() { let (repo, _db) = repo().await; + seed_connection(&repo).await; repo.create_user(OWNER_ID, &make_user("u1", "tg_1", "telegram")) .await .unwrap(); @@ -240,6 +336,13 @@ async fn pc1_same_user_different_chat_ids() { assert_ne!(s1.id, s2.id); assert_eq!(repo.get_all_sessions(OWNER_ID).await.unwrap().len(), 2); + + // Both bindings carry the identity derived from the channel user, not the + // empty owner/connection the caller passed in. + for s in [&s1, &s2] { + assert_eq!(s.owner_user_id, OWNER_ID); + assert_eq!(s.connection_id, TG_CONN); + } } // ── PC-2: Different users, same chatId → different sessions ────────── @@ -247,6 +350,7 @@ async fn pc1_same_user_different_chat_ids() { #[tokio::test] async fn pc2_different_users_same_chat_id() { let (repo, _db) = repo().await; + seed_connection(&repo).await; repo.create_user(OWNER_ID, &make_user("u1", "tg_1", "telegram")) .await .unwrap(); @@ -271,6 +375,7 @@ async fn pc2_different_users_same_chat_id() { #[tokio::test] async fn pc3_same_user_same_chat_reuses_session() { let (repo, _db) = repo().await; + seed_connection(&repo).await; repo.create_user(OWNER_ID, &make_user("u1", "tg_1", "telegram")) .await .unwrap(); @@ -296,11 +401,20 @@ async fn pc3_same_user_same_chat_reuses_session() { #[tokio::test] async fn pg2_pairing_code_expiry_is_10_minutes() { let (repo, _db) = repo().await; - let pairing = make_pairing("123456", "tg_99", 600_000); + seed_connection(&repo).await; + let pairing = make_pairing("p1", "123456", "tg_99", 600_000); repo.create_pairing(OWNER_ID, &pairing).await.unwrap(); - let found = repo.get_pairing_by_code(OWNER_ID, "123456").await.unwrap().unwrap(); + let found = repo.get_pairing(OWNER_ID, "p1").await.unwrap().unwrap(); assert_eq!(found.expires_at - found.requested_at, 600_000); + + // The code is addressable only through its hash while pending. + let by_hash = repo + .get_pending_pairing_by_code_hash(OWNER_ID, "hash-123456") + .await + .unwrap() + .unwrap(); + assert_eq!(by_hash.id, "p1"); } // ── EC-1 / EC-2: Expired pairings cleaned up, valid ones preserved ── @@ -308,24 +422,25 @@ async fn pg2_pairing_code_expiry_is_10_minutes() { #[tokio::test] async fn expired_pairings_cleaned_up() { let (repo, _db) = repo().await; + seed_connection(&repo).await; let now = aionui_common::now_ms(); // Already expired. - repo.create_pairing(OWNER_ID, &make_pairing("111111", "tg_1", -1000)) + repo.create_pairing(OWNER_ID, &make_pairing("p-old", "111111", "tg_1", -1000)) .await .unwrap(); // Still valid. - repo.create_pairing(OWNER_ID, &make_pairing("222222", "tg_2", 600_000)) + repo.create_pairing(OWNER_ID, &make_pairing("p-new", "222222", "tg_2", 600_000)) .await .unwrap(); let cleaned = repo.cleanup_expired_pairings(OWNER_ID, now).await.unwrap(); assert_eq!(cleaned, 1); - let expired = repo.get_pairing_by_code(OWNER_ID, "111111").await.unwrap().unwrap(); + let expired = repo.get_pairing(OWNER_ID, "p-old").await.unwrap().unwrap(); assert_eq!(expired.status, "expired"); - let valid = repo.get_pairing_by_code(OWNER_ID, "222222").await.unwrap().unwrap(); + let valid = repo.get_pairing(OWNER_ID, "p-new").await.unwrap().unwrap(); assert_eq!(valid.status, "pending"); } @@ -334,17 +449,21 @@ async fn expired_pairings_cleaned_up() { #[tokio::test] async fn pairing_approve_and_reject() { let (repo, _db) = repo().await; - repo.create_pairing(OWNER_ID, &make_pairing("100001", "tg_a", 600_000)) + seed_connection(&repo).await; + repo.create_user(OWNER_ID, &make_user("u-approved", "tg_a", "telegram")) + .await + .unwrap(); + repo.create_pairing(OWNER_ID, &make_pairing("p-a", "100001", "tg_a", 600_000)) .await .unwrap(); - repo.create_pairing(OWNER_ID, &make_pairing("100002", "tg_b", 600_000)) + repo.create_pairing(OWNER_ID, &make_pairing("p-b", "100002", "tg_b", 600_000)) .await .unwrap(); - repo.update_pairing_status(OWNER_ID, "100001", "approved") + repo.update_pairing_status(OWNER_ID, "p-a", "approved", Some("u-approved")) .await .unwrap(); - repo.update_pairing_status(OWNER_ID, "100002", "rejected") + repo.update_pairing_status(OWNER_ID, "p-b", "rejected", None) .await .unwrap(); @@ -352,21 +471,27 @@ async fn pairing_approve_and_reject() { let pending = repo.get_pending_pairings(OWNER_ID).await.unwrap(); assert!(pending.is_empty()); - assert_eq!( - repo.get_pairing_by_code(OWNER_ID, "100001") + let approved = repo.get_pairing(OWNER_ID, "p-a").await.unwrap().unwrap(); + assert_eq!(approved.status, "approved"); + // An approval records which channel user it created. + assert_eq!(approved.approved_channel_user_id.as_deref(), Some("u-approved")); + + let rejected = repo.get_pairing(OWNER_ID, "p-b").await.unwrap().unwrap(); + assert_eq!(rejected.status, "rejected"); + assert_eq!(rejected.approved_channel_user_id, None); + + // Processed requests are no longer resolvable by code hash. + assert!( + repo.get_pending_pairing_by_code_hash(OWNER_ID, "hash-100001") .await .unwrap() - .unwrap() - .status, - "approved" + .is_none() ); - assert_eq!( - repo.get_pairing_by_code(OWNER_ID, "100002") + assert!( + repo.get_pending_pairing_by_code_hash(OWNER_ID, "hash-100002") .await .unwrap() - .unwrap() - .status, - "rejected" + .is_none() ); } @@ -375,6 +500,7 @@ async fn pairing_approve_and_reject() { #[tokio::test] async fn users_ordered_by_authorized_at_desc() { let (repo, _db) = repo().await; + seed_connection(&repo).await; let mut u1 = make_user("u1", "tg_1", "telegram"); u1.authorized_at = 1000; diff --git a/crates/aionui-db/tests/client_preference_scope_migration.rs b/crates/aionui-db/tests/client_preference_scope_migration.rs new file mode 100644 index 000000000..d01135845 --- /dev/null +++ b/crates/aionui-db/tests/client_preference_scope_migration.rs @@ -0,0 +1,332 @@ +//! Migration 030 segment 4: device/account scope for `client_preferences`. +//! +//! Seeds a realistic pre-031 database (two users, each with a machine-level +//! preference, a personal preference and a `system_settings` row), runs the +//! migration, and asserts the promotion/materialization contract from +//! `031_client_preference_scope.sql`. + +use std::borrow::Cow; +use std::path::Path; + +use sqlx::Row; +use sqlx::migrate::Migrator; +use sqlx::sqlite::SqlitePoolOptions; + +const USER_A: &str = "system_default_user"; +const USER_B: &str = "user-b"; + +async fn run_migrations_through(pool: &sqlx::SqlitePool, max_version: i64) { + sqlx::query("PRAGMA foreign_keys = OFF").execute(pool).await.unwrap(); + let full = Migrator::new(Path::new("migrations")).await.unwrap(); + let migrations = full + .migrations + .iter() + .filter(|migration| migration.version <= max_version) + .cloned() + .collect::>(); + let migrator = Migrator { + migrations: Cow::Owned(migrations), + ignore_missing: false, + locking: true, + no_tx: false, + }; + migrator.run(pool).await.unwrap(); +} + +async fn run_migration(pool: &sqlx::SqlitePool, version: i64) { + let full = Migrator::new(Path::new("migrations")).await.unwrap(); + let migrations = full + .migrations + .iter() + .filter(|migration| migration.version == version) + .cloned() + .collect::>(); + let migrator = Migrator { + migrations: Cow::Owned(migrations), + ignore_missing: true, + locking: true, + no_tx: false, + }; + migrator.run(pool).await.unwrap(); +} + +async fn insert_pref(pool: &sqlx::SqlitePool, user_id: &str, key: &str, value: &str, updated_at: i64) { + sqlx::query("INSERT INTO client_preferences (user_id, key, value, updated_at) VALUES (?, ?, ?, ?)") + .bind(user_id) + .bind(key) + .bind(value) + .bind(updated_at) + .execute(pool) + .await + .unwrap(); +} + +async fn insert_settings( + pool: &sqlx::SqlitePool, + user_id: &str, + language: &str, + notification: bool, + cron_notification: bool, + command_queue: bool, + save_upload: bool, +) { + sqlx::query( + "INSERT INTO system_settings ( + user_id, language, notification_enabled, cron_notification_enabled, + command_queue_enabled, save_upload_to_workspace, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 100)", + ) + .bind(user_id) + .bind(language) + .bind(notification) + .bind(cron_notification) + .bind(command_queue) + .bind(save_upload) + .execute(pool) + .await + .unwrap(); +} + +/// Pre-031 fixture: two users, machine-level keys copied per user (with +/// different values and update times), personal keys, and settings rows. +async fn seed_pre_031() -> sqlx::SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + run_migrations_through(&pool, 30).await; + + for user_id in [USER_A, USER_B] { + sqlx::query( + "INSERT INTO users (id, user_type, username, password_hash, status, session_generation, created_at, updated_at) \ + VALUES (?, 'local', ?, '', 'active', 0, 1, 1)", + ) + .bind(user_id) + .bind(user_id) + .execute(&pool) + .await + .unwrap(); + } + + // Machine-level keys, duplicated per user. USER_B wrote later, so its + // value is the machine's most recent truth. + insert_pref(&pool, USER_A, "system.closeToTray", "true", 100).await; + insert_pref(&pool, USER_B, "system.closeToTray", "false", 200).await; + insert_pref(&pool, USER_A, "keepAwake", "true", 300).await; + insert_pref(&pool, USER_A, "pet.size", "360", 400).await; + insert_pref(&pool, USER_B, "pet.size", "480", 500).await; + insert_pref(&pool, USER_B, "autoPreviewOfficeFiles", "true", 600).await; + + // Personal (account-scope) keys. + insert_pref(&pool, USER_A, "theme", "\"dark\"", 700).await; + insert_pref(&pool, USER_B, "theme", "\"light\"", 700).await; + insert_pref(&pool, USER_A, "language", "\"zh-CN\"", 700).await; + + // A preference written after B1 must not be clobbered by the column + // materialization, even though the column disagrees. + insert_pref(&pool, USER_A, "system.notificationEnabled", "false", 800).await; + + insert_settings(&pool, USER_A, "zh-CN", true, false, true, false).await; + insert_settings(&pool, USER_B, "en-US", false, true, false, true).await; + + run_migration(&pool, 31).await; + pool +} + +async fn device_value(pool: &sqlx::SqlitePool, key: &str) -> Option<(Option, String)> { + let row = sqlx::query("SELECT user_id, value FROM client_preferences WHERE scope = 'device' AND key = ?") + .bind(key) + .fetch_optional(pool) + .await + .unwrap(); + row.map(|row| (row.get::, _>("user_id"), row.get::("value"))) +} + +async fn account_value(pool: &sqlx::SqlitePool, user_id: &str, key: &str) -> Option { + sqlx::query_scalar("SELECT value FROM client_preferences WHERE scope = 'account' AND user_id = ? AND key = ?") + .bind(user_id) + .bind(key) + .fetch_optional(pool) + .await + .unwrap() +} + +async fn row_count(pool: &sqlx::SqlitePool, sql: &str) -> i64 { + sqlx::query_scalar(sql).fetch_one(pool).await.unwrap() +} + +#[tokio::test] +async fn migration_031_promotes_device_keys_to_a_single_machine_row() { + let pool = seed_pre_031().await; + + // Latest write wins across users; the row is owned by no account. + assert_eq!( + device_value(&pool, "system.closeToTray").await, + Some((None, "false".to_owned())), + "USER_B's later value must win" + ); + assert_eq!(device_value(&pool, "pet.size").await, Some((None, "480".to_owned()))); + assert_eq!(device_value(&pool, "keepAwake").await, Some((None, "true".to_owned()))); + assert_eq!( + device_value(&pool, "autoPreviewOfficeFiles").await, + Some((None, "true".to_owned())) + ); + + // Exactly one copy of each device key survives. + for key in ["system.closeToTray", "pet.size", "keepAwake", "autoPreviewOfficeFiles"] { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM client_preferences WHERE key = ?") + .bind(key) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1, "{key} must exist exactly once after promotion"); + } +} + +#[tokio::test] +async fn migration_031_drops_the_per_user_copies_of_device_keys() { + let pool = seed_pre_031().await; + + for user_id in [USER_A, USER_B] { + for key in ["system.closeToTray", "pet.size", "keepAwake", "autoPreviewOfficeFiles"] { + assert_eq!( + account_value(&pool, user_id, key).await, + None, + "{user_id} must not keep an account copy of the device key {key}" + ); + } + } + + assert_eq!( + row_count( + &pool, + "SELECT COUNT(*) FROM client_preferences WHERE scope = 'account' AND user_id IS NULL" + ) + .await, + 0, + "account rows must always have an owner" + ); + assert_eq!( + row_count( + &pool, + "SELECT COUNT(*) FROM client_preferences WHERE scope = 'device' AND user_id IS NOT NULL" + ) + .await, + 0, + "device rows must never have an owner" + ); +} + +#[tokio::test] +async fn migration_031_leaves_personal_keys_untouched() { + let pool = seed_pre_031().await; + + assert_eq!(account_value(&pool, USER_A, "theme").await, Some("\"dark\"".to_owned())); + assert_eq!( + account_value(&pool, USER_B, "theme").await, + Some("\"light\"".to_owned()) + ); + assert_eq!( + account_value(&pool, USER_A, "language").await, + Some("\"zh-CN\"".to_owned()) + ); + assert_eq!(account_value(&pool, USER_B, "language").await, None); + + // The personal key must not have leaked into the device scope. + assert_eq!(device_value(&pool, "theme").await, None); +} + +#[tokio::test] +async fn migration_031_materializes_the_four_switches_per_user() { + let pool = seed_pre_031().await; + + // USER_A columns: notification=1, cron=0, queue=1, save=0. + // `system.notificationEnabled` was already stored as `false` and must win. + assert_eq!( + account_value(&pool, USER_A, "system.notificationEnabled").await, + Some("false".to_owned()), + "an existing preference is the newer truth and must survive the migration" + ); + assert_eq!( + account_value(&pool, USER_A, "cron.notificationEnabled").await, + Some("false".to_owned()) + ); + assert_eq!( + account_value(&pool, USER_A, "system.commandQueueEnabled").await, + Some("true".to_owned()) + ); + assert_eq!( + account_value(&pool, USER_A, "system.saveUploadToWorkspace").await, + Some("false".to_owned()) + ); + + // USER_B columns: notification=0, cron=1, queue=0, save=1. + assert_eq!( + account_value(&pool, USER_B, "system.notificationEnabled").await, + Some("false".to_owned()) + ); + assert_eq!( + account_value(&pool, USER_B, "cron.notificationEnabled").await, + Some("true".to_owned()) + ); + assert_eq!( + account_value(&pool, USER_B, "system.commandQueueEnabled").await, + Some("false".to_owned()) + ); + assert_eq!( + account_value(&pool, USER_B, "system.saveUploadToWorkspace").await, + Some("true".to_owned()) + ); + + // The legacy columns are still there (B3 drops them later). + let notification: bool = sqlx::query_scalar("SELECT notification_enabled FROM system_settings WHERE user_id = ?") + .bind(USER_A) + .fetch_one(&pool) + .await + .unwrap(); + assert!(notification, "the legacy column must be left as-is by 031"); +} + +#[tokio::test] +async fn migration_031_enforces_scope_uniqueness_and_ownership() { + let pool = seed_pre_031().await; + + // One device row per key. + let duplicate = sqlx::query("INSERT INTO client_preferences (scope, user_id, key, value, updated_at) VALUES ('device', NULL, 'keepAwake', 'false', 900)") + .execute(&pool) + .await; + assert!( + duplicate.is_err(), + "a second device row for the same key must be rejected" + ); + + // One account row per (user, key). + let duplicate = sqlx::query("INSERT INTO client_preferences (scope, user_id, key, value, updated_at) VALUES ('account', ?, 'theme', '\"blue\"', 900)") + .bind(USER_A) + .execute(&pool) + .await; + assert!( + duplicate.is_err(), + "a second account row for the same (user, key) must be rejected" + ); + + // But the same key for a different user is fine. + sqlx::query("INSERT INTO client_preferences (scope, user_id, key, value, updated_at) VALUES ('account', ?, 'keepAwake', 'true', 900)") + .bind(USER_B) + .execute(&pool) + .await + .expect("account scope is independent of the device row"); + + // Ownership CHECK: device rows may not carry a user, account rows must. + let bad_device = sqlx::query("INSERT INTO client_preferences (scope, user_id, key, value, updated_at) VALUES ('device', ?, 'window.mode', 'x', 900)") + .bind(USER_A) + .execute(&pool) + .await; + assert!(bad_device.is_err(), "device rows must have a NULL user_id"); + + let bad_account = sqlx::query("INSERT INTO client_preferences (scope, user_id, key, value, updated_at) VALUES ('account', NULL, 'window.mode', 'x', 900)") + .execute(&pool) + .await; + assert!(bad_account.is_err(), "account rows must have a user_id"); +} diff --git a/crates/aionui-db/tests/feedback_diagnostics_repository.rs b/crates/aionui-db/tests/feedback_diagnostics_repository.rs index b661327cf..f1047b5cd 100644 --- a/crates/aionui-db/tests/feedback_diagnostics_repository.rs +++ b/crates/aionui-db/tests/feedback_diagnostics_repository.rs @@ -512,8 +512,9 @@ async fn insert_feedback_fixture(db: &aionui_db::Database) { } sqlx::query( - "INSERT INTO client_preferences (user_id, key, value, updated_at) VALUES (?, ?, ?, ?) \ - ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + "INSERT INTO client_preferences (scope, user_id, key, value, updated_at) VALUES ('account', ?, ?, ?, ?) \ + ON CONFLICT(user_id, key) WHERE scope = 'account' \ + DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", ) .bind("system_default_user") .bind("appearance.uiScale") diff --git a/crates/aionui-db/tests/user_scope_migration.rs b/crates/aionui-db/tests/user_scope_migration.rs index a7cdf3bb0..979905faa 100644 --- a/crates/aionui-db/tests/user_scope_migration.rs +++ b/crates/aionui-db/tests/user_scope_migration.rs @@ -90,9 +90,12 @@ async fn migration_030_adds_user_scope_to_independent_roots() { ("oauth_tokens", "user_id"), ("system_settings", "user_id"), ("client_preferences", "user_id"), - ("assistant_plugins", "owner_user_id"), - ("assistant_users", "owner_user_id"), - ("assistant_pairing_codes", "owner_user_id"), + // 030 renamed the channel tables: assistant_plugins → + // channel_connections (connection entity), assistant_users → + // channel_users, assistant_pairing_codes → channel_pairing_requests. + ("channel_connections", "owner_user_id"), + ("channel_users", "owner_user_id"), + ("channel_pairing_requests", "owner_user_id"), ] { let columns = table_columns(db.pool(), table).await; assert!( diff --git a/crates/aionui-system/src/client_pref.rs b/crates/aionui-system/src/client_pref.rs index f16c13591..1e5a31d77 100644 --- a/crates/aionui-system/src/client_pref.rs +++ b/crates/aionui-system/src/client_pref.rs @@ -11,6 +11,21 @@ use crate::keep_awake::{DynKeepAwakeController, KEEP_AWAKE_KEY, NoopKeepAwakeCon /// Maximum allowed key length for client preferences. const MAX_KEY_LENGTH: usize = 255; +/// Preference keys that describe the machine, not the account. They are stored +/// once per device (`scope = 'device'`, no owning user) so every account on the +/// machine reads and writes the same value. Confirmed by the settings-dedup B1 +/// inventory and promoted by migration 031. +const DEVICE_SCOPED_KEYS: &[&str] = &["system.closeToTray", KEEP_AWAKE_KEY, "autoPreviewOfficeFiles"]; + +/// Key prefixes that are device-scoped as a family (desktop pet geometry and +/// visibility describe the screen, not the account). +const DEVICE_SCOPED_KEY_PREFIXES: &[&str] = &["pet."]; + +/// Whether a preference key is stored machine-level rather than per-account. +fn is_device_scoped_key(key: &str) -> bool { + DEVICE_SCOPED_KEYS.contains(&key) || DEVICE_SCOPED_KEY_PREFIXES.iter().any(|prefix| key.starts_with(prefix)) +} + /// Business logic for client preferences (generic key-value store). #[derive(Clone)] pub struct ClientPrefService { @@ -26,41 +41,63 @@ impl ClientPrefService { } } + /// Build the service and restore the persisted keep-awake assertion. + /// + /// `keepAwake` is device-scoped, so the restore needs no user: whatever the + /// machine last stored is reapplied at startup regardless of who logs in. pub fn with_keep_awake_controller( repo: Arc, keep_awake_controller: DynKeepAwakeController, - keep_awake_restore_user_id: impl Into, ) -> Self { - let service = Self::with_keep_awake_controller_without_restore(repo, keep_awake_controller); - service.restore_keep_awake_from_preferences(keep_awake_restore_user_id.into()); - service - } - - pub fn with_keep_awake_controller_without_restore( - repo: Arc, - keep_awake_controller: DynKeepAwakeController, - ) -> Self { - Self { + let service = Self { repo, keep_awake_controller, - } + }; + service.restore_keep_awake_from_preferences(); + service } /// Get all client preferences, or only the specified keys. + /// + /// The response merges the caller's account-scope rows with the machine's + /// device-scope rows; the two sets never share a key (write routing sends + /// each key to exactly one scope, and migration 031 moved the historical + /// per-user copies of device keys into the single device row). pub async fn get_preferences( &self, user_id: &str, keys: Option<&[&str]>, ) -> Result { - let rows = match keys { - Some(k) if !k.is_empty() => self.repo.get_by_keys(user_id, k).await, - _ => self.repo.get_all(user_id).await, - } - .map_err(|e| SystemError::Internal(format!("Failed to get preferences: {e}")))?; + let (account_rows, device_rows) = match keys { + Some(requested) if !requested.is_empty() => { + let (device_keys, account_keys): (Vec<&str>, Vec<&str>) = + requested.iter().copied().partition(|key| is_device_scoped_key(key)); + ( + self.repo + .get_by_keys(user_id, &account_keys) + .await + .map_err(|e| SystemError::Internal(format!("Failed to get preferences: {e}")))?, + self.repo + .get_device_by_keys(&device_keys) + .await + .map_err(|e| SystemError::Internal(format!("Failed to get device preferences: {e}")))?, + ) + } + _ => ( + self.repo + .get_all(user_id) + .await + .map_err(|e| SystemError::Internal(format!("Failed to get preferences: {e}")))?, + self.repo + .get_all_device() + .await + .map_err(|e| SystemError::Internal(format!("Failed to get device preferences: {e}")))?, + ), + }; let mut found_keys = BTreeSet::new(); let mut map = ClientPreferencesResponse::new(); - for row in rows { + for row in account_rows.into_iter().chain(device_rows) { let value: serde_json::Value = serde_json::from_str(&row.value).unwrap_or(serde_json::Value::String(row.value)); found_keys.insert(row.key.clone()); @@ -126,7 +163,7 @@ impl ClientPrefService { } let previous_keep_awake = if keep_awake_update.is_some() { - Some(self.get_stored_keep_awake(user_id).await?) + Some(self.get_stored_keep_awake().await?) } else { None }; @@ -135,34 +172,52 @@ impl ClientPrefService { self.apply_keep_awake(enabled).await?; } - if !upserts.is_empty() { - let entries: Vec<(&str, &str)> = upserts.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); - if let Err(error) = self - .repo - .upsert_batch(user_id, &entries) - .await - .map_err(|e| SystemError::Internal(format!("Failed to upsert preferences: {e}"))) - { - if let Some(previous) = previous_keep_awake { - let _ = self.apply_keep_awake(previous).await; - } - return Err(error); + // Route each key to the scope it is stored in. + let (device_upserts, account_upserts): (Vec<_>, Vec<_>) = upserts + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .partition(|(key, _)| is_device_scoped_key(key)); + let (device_deletes, account_deletes): (Vec<&str>, Vec<&str>) = deletes + .iter() + .map(|key| key.as_str()) + .partition(|key| is_device_scoped_key(key)); + + let stored = async { + if !account_upserts.is_empty() { + self.repo + .upsert_batch(user_id, &account_upserts) + .await + .map_err(|e| SystemError::Internal(format!("Failed to upsert preferences: {e}")))?; + } + if !device_upserts.is_empty() { + self.repo + .upsert_device_batch(&device_upserts) + .await + .map_err(|e| SystemError::Internal(format!("Failed to upsert device preferences: {e}")))?; + } + if !account_deletes.is_empty() { + self.repo + .delete_keys(user_id, &account_deletes) + .await + .map_err(|e| SystemError::Internal(format!("Failed to delete preferences: {e}")))?; + } + if !device_deletes.is_empty() { + self.repo + .delete_device_keys(&device_deletes) + .await + .map_err(|e| SystemError::Internal(format!("Failed to delete device preferences: {e}")))?; } + Ok::<(), SystemError>(()) } + .await; - if !deletes.is_empty() { - let keys: Vec<&str> = deletes.iter().map(|k| k.as_str()).collect(); - if let Err(error) = self - .repo - .delete_keys(user_id, &keys) - .await - .map_err(|e| SystemError::Internal(format!("Failed to delete preferences: {e}"))) - { - if let Some(previous) = previous_keep_awake { - let _ = self.apply_keep_awake(previous).await; - } - return Err(error); + if let Err(error) = stored { + // Persistence failed after the assertion was already flipped — + // put the machine back where it was. + if let Some(previous) = previous_keep_awake { + let _ = self.apply_keep_awake(previous).await; } + return Err(error); } Ok(()) @@ -177,10 +232,12 @@ impl ClientPrefService { Ok(()) } - async fn get_stored_keep_awake(&self, user_id: &str) -> Result { + /// Reads the machine's stored keep-awake value. Device-scoped: there is one + /// value per machine, so no user is involved. + async fn get_stored_keep_awake(&self) -> Result { let rows = self .repo - .get_by_keys(user_id, &[KEEP_AWAKE_KEY]) + .get_device_by_keys(&[KEEP_AWAKE_KEY]) .await .map_err(|e| SystemError::Internal(format!("Failed to get keep-awake preference: {e}")))?; @@ -206,14 +263,14 @@ impl ClientPrefService { Ok(()) } - fn restore_keep_awake_from_preferences(&self, user_id: String) { + fn restore_keep_awake_from_preferences(&self) { let service = self.clone(); let Ok(handle) = tokio::runtime::Handle::try_current() else { warn!("Cannot restore system keep-awake preference without a Tokio runtime"); return; }; handle.spawn(async move { - match service.get_stored_keep_awake(&user_id).await { + match service.get_stored_keep_awake().await { Ok(true) => { if let Err(error) = service.apply_keep_awake(true).await { warn!(error = %error, "Failed to restore system keep-awake assertion"); @@ -275,6 +332,7 @@ mod tests { use tracing_subscriber::fmt; const TEST_USER_ID: &str = "user-1"; + const OTHER_USER_ID: &str = "user-2"; #[derive(Clone)] struct SharedBuf(Arc>>); @@ -315,37 +373,32 @@ mod tests { String::from_utf8(buffer.lock().unwrap().clone()).unwrap() } - async fn setup() -> ClientPrefService { + async fn setup_repo() -> Arc { let db = init_database_memory().await.unwrap(); - sqlx::query( - "INSERT INTO users (id, user_type, username, password_hash, status, session_generation, created_at, updated_at) \ - VALUES (?, 'local', ?, '', 'active', 0, 1, 1)", - ) - .bind(TEST_USER_ID) - .bind(TEST_USER_ID) - .execute(db.pool()) - .await - .unwrap(); + for user_id in [TEST_USER_ID, OTHER_USER_ID] { + sqlx::query( + "INSERT INTO users (id, user_type, username, password_hash, status, session_generation, created_at, updated_at) \ + VALUES (?, 'local', ?, '', 'active', 0, 1, 1)", + ) + .bind(user_id) + .bind(user_id) + .execute(db.pool()) + .await + .unwrap(); + } let repo = Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone())); + // Leak the db handle so the pool stays alive for the test std::mem::forget(db); - ClientPrefService::new(repo) + repo + } + + async fn setup() -> ClientPrefService { + ClientPrefService::new(setup_repo().await) } async fn setup_with_keep_awake_controller(controller: DynKeepAwakeController) -> ClientPrefService { - let db = init_database_memory().await.unwrap(); - sqlx::query( - "INSERT INTO users (id, user_type, username, password_hash, status, session_generation, created_at, updated_at) \ - VALUES (?, 'local', ?, '', 'active', 0, 1, 1)", - ) - .bind(TEST_USER_ID) - .bind(TEST_USER_ID) - .execute(db.pool()) - .await - .unwrap(); - let repo = Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone())); - std::mem::forget(db); ClientPrefService { - repo, + repo: setup_repo().await, keep_awake_controller: controller, } } @@ -653,8 +706,7 @@ mod tests { initial.update_preferences(TEST_USER_ID, req).await.unwrap(); let controller = Arc::new(RecordingKeepAwakeController::default()); - let service = - ClientPrefService::with_keep_awake_controller(initial.repo.clone(), controller.clone(), TEST_USER_ID); + let service = ClientPrefService::with_keep_awake_controller(initial.repo.clone(), controller.clone()); for _ in 0..50 { if !controller.calls.lock().unwrap().is_empty() { @@ -668,19 +720,187 @@ mod tests { } #[tokio::test] - async fn keep_awake_without_restore_does_not_read_persisted_default_user_preference() { + async fn keep_awake_restore_reads_the_machine_value_written_by_any_account() { + // `keepAwake` is device-scoped: the value another account stored is the + // machine's value, and startup restore must pick it up without knowing + // which user wrote it. let initial = setup().await; let mut req = UpdateClientPreferencesRequest::new(); req.insert(KEEP_AWAKE_KEY.into(), json!(true)); - initial.update_preferences(TEST_USER_ID, req).await.unwrap(); + initial.update_preferences(OTHER_USER_ID, req).await.unwrap(); let controller = Arc::new(RecordingKeepAwakeController::default()); - let service = - ClientPrefService::with_keep_awake_controller_without_restore(initial.repo.clone(), controller.clone()); + let service = ClientPrefService::with_keep_awake_controller(initial.repo.clone(), controller.clone()); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; + for _ in 0..50 { + if !controller.calls.lock().unwrap().is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } - assert!(controller.calls.lock().unwrap().is_empty()); + assert_eq!(*controller.calls.lock().unwrap(), vec![true]); + drop(service); + } + + #[tokio::test] + async fn keep_awake_restore_leaves_controller_untouched_without_a_stored_value() { + let initial = setup().await; + let controller = Arc::new(RecordingKeepAwakeController::default()); + let service = ClientPrefService::with_keep_awake_controller(initial.repo.clone(), controller.clone()); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + assert!( + controller.calls.lock().unwrap().is_empty(), + "restore must not assert keep-awake when the machine stored nothing" + ); drop(service); } + + // -- device vs account scope -- + + #[test] + fn device_scoped_keys_are_the_machine_level_set() { + for key in ["system.closeToTray", "keepAwake", "autoPreviewOfficeFiles"] { + assert!(is_device_scoped_key(key), "{key} must be device-scoped"); + } + for key in ["pet.", "pet.size", "pet.visible", "pet.anything.nested"] { + assert!(is_device_scoped_key(key), "{key} must be device-scoped"); + } + } + + #[test] + fn account_scoped_keys_are_not_device_scoped() { + for key in [ + "theme", + "language", + "system.notificationEnabled", + "system.saveUploadToWorkspace", + "assistant.telegram.agent", + // Near-misses: the prefix rule must not swallow these. + "petals", + "pet", + "appearance.pet.size", + "system.closeToTrayExtra", + ] { + assert!(!is_device_scoped_key(key), "{key} must be account-scoped"); + } + } + + #[tokio::test] + async fn device_key_written_by_one_user_is_visible_to_another() { + let svc = setup().await; + let mut req = UpdateClientPreferencesRequest::new(); + req.insert("system.closeToTray".into(), json!(true)); + req.insert("pet.size".into(), json!(360)); + svc.update_preferences(TEST_USER_ID, req).await.unwrap(); + + // Full read for the other account sees the machine's values… + let prefs = svc.get_preferences(OTHER_USER_ID, None).await.unwrap(); + assert_eq!(prefs["system.closeToTray"], json!(true)); + assert_eq!(prefs["pet.size"], json!(360)); + + // …and so does a keyed read. + let prefs = svc + .get_preferences(OTHER_USER_ID, Some(&["system.closeToTray", "pet.size"])) + .await + .unwrap(); + assert_eq!(prefs["system.closeToTray"], json!(true)); + assert_eq!(prefs["pet.size"], json!(360)); + } + + #[tokio::test] + async fn device_key_write_by_second_user_overwrites_the_machine_value() { + let svc = setup().await; + let mut first = UpdateClientPreferencesRequest::new(); + first.insert("autoPreviewOfficeFiles".into(), json!(true)); + svc.update_preferences(TEST_USER_ID, first).await.unwrap(); + + let mut second = UpdateClientPreferencesRequest::new(); + second.insert("autoPreviewOfficeFiles".into(), json!(false)); + svc.update_preferences(OTHER_USER_ID, second).await.unwrap(); + + for user in [TEST_USER_ID, OTHER_USER_ID] { + let prefs = svc.get_preferences(user, None).await.unwrap(); + assert_eq!(prefs["autoPreviewOfficeFiles"], json!(false), "for {user}"); + } + } + + #[tokio::test] + async fn device_key_delete_removes_it_for_every_account() { + let svc = setup().await; + let mut req = UpdateClientPreferencesRequest::new(); + req.insert("pet.size".into(), json!(360)); + svc.update_preferences(TEST_USER_ID, req).await.unwrap(); + + let mut delete = UpdateClientPreferencesRequest::new(); + delete.insert("pet.size".into(), json!(null)); + svc.update_preferences(OTHER_USER_ID, delete).await.unwrap(); + + let prefs = svc.get_preferences(TEST_USER_ID, None).await.unwrap(); + assert!(!prefs.contains_key("pet.size")); + } + + #[tokio::test] + async fn account_keys_stay_isolated_between_users() { + let svc = setup().await; + let mut mine = UpdateClientPreferencesRequest::new(); + mine.insert("theme".into(), json!("dark")); + svc.update_preferences(TEST_USER_ID, mine).await.unwrap(); + + let mut theirs = UpdateClientPreferencesRequest::new(); + theirs.insert("theme".into(), json!("light")); + svc.update_preferences(OTHER_USER_ID, theirs).await.unwrap(); + + assert_eq!( + svc.get_preferences(TEST_USER_ID, None).await.unwrap()["theme"], + json!("dark") + ); + assert_eq!( + svc.get_preferences(OTHER_USER_ID, None).await.unwrap()["theme"], + json!("light") + ); + + // Deleting one account's key leaves the other's alone. + let mut delete = UpdateClientPreferencesRequest::new(); + delete.insert("theme".into(), json!(null)); + svc.update_preferences(OTHER_USER_ID, delete).await.unwrap(); + assert!( + !svc.get_preferences(OTHER_USER_ID, None) + .await + .unwrap() + .contains_key("theme") + ); + assert_eq!( + svc.get_preferences(TEST_USER_ID, None).await.unwrap()["theme"], + json!("dark") + ); + } + + #[tokio::test] + async fn mixed_scope_batch_routes_each_key_to_its_own_store() { + let svc = setup().await; + let mut req = UpdateClientPreferencesRequest::new(); + req.insert("pet.size".into(), json!(360)); + req.insert("theme".into(), json!("dark")); + svc.update_preferences(TEST_USER_ID, req).await.unwrap(); + + // The device key lives in the device store only… + let device_rows = svc.repo.get_all_device().await.unwrap(); + let device_keys: Vec<&str> = device_rows.iter().map(|row| row.key.as_str()).collect(); + assert_eq!(device_keys, vec!["pet.size"]); + assert!(device_rows[0].user_id.is_none()); + + // …and the account key in the account store only. + let account_rows = svc.repo.get_all(TEST_USER_ID).await.unwrap(); + let account_keys: Vec<&str> = account_rows.iter().map(|row| row.key.as_str()).collect(); + assert_eq!(account_keys, vec!["theme"]); + + // The merged read still returns both. + let prefs = svc.get_preferences(TEST_USER_ID, None).await.unwrap(); + assert_eq!(prefs.len(), 2); + assert_eq!(prefs["pet.size"], json!(360)); + assert_eq!(prefs["theme"], json!("dark")); + } } diff --git a/crates/aionui-system/src/settings.rs b/crates/aionui-system/src/settings.rs index d8b7322e4..901e4b890 100644 --- a/crates/aionui-system/src/settings.rs +++ b/crates/aionui-system/src/settings.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use aionui_api_types::{SystemSettingsResponse, UpdateSettingsRequest}; -use aionui_db::ISettingsRepository; +use aionui_db::{IClientPreferenceRepository, ISettingsRepository}; +use tracing::warn; use crate::error::SystemError; @@ -11,15 +12,44 @@ const SUPPORTED_LANGUAGES: &[&str] = &[ "nl-NL", "pl-PL", "tr-TR", "vi-VN", "th-TH", "id-ID", ]; +/// Client-preference key that is the single source of truth for the UI +/// language. `system_settings.language` is a legacy read fallback only; +/// writes always land here (settings-dedup B1). +const LANGUAGE_PREF_KEY: &str = "language"; + +/// Account-scope preference keys that are the single source of truth for the +/// four boolean switches; the matching `system_settings` columns are legacy +/// read fallbacks only (settings-dedup B2, migration 031 materialized them). +const NOTIFICATION_ENABLED_PREF_KEY: &str = "system.notificationEnabled"; +const CRON_NOTIFICATION_ENABLED_PREF_KEY: &str = "cron.notificationEnabled"; +const COMMAND_QUEUE_ENABLED_PREF_KEY: &str = "system.commandQueueEnabled"; +const SAVE_UPLOAD_TO_WORKSPACE_PREF_KEY: &str = "system.saveUploadToWorkspace"; + +/// Every preference key this service owns, in one read batch. +const SETTINGS_PREF_KEYS: &[&str] = &[ + LANGUAGE_PREF_KEY, + NOTIFICATION_ENABLED_PREF_KEY, + CRON_NOTIFICATION_ENABLED_PREF_KEY, + COMMAND_QUEUE_ENABLED_PREF_KEY, + SAVE_UPLOAD_TO_WORKSPACE_PREF_KEY, +]; + /// Business logic for system settings (language, notifications, etc.). +/// +/// Every field is proxied to `client_preferences` — the same keys the frontend +/// reads/writes via `/api/settings/client` — so there is exactly one stored +/// truth. The `system_settings` columns are kept convergent on write and serve +/// as the read fallback for rows that predate the preference materialization; +/// they go away when B3 drops the table. #[derive(Clone)] pub struct SettingsService { repo: Arc, + pref_repo: Arc, } impl SettingsService { - pub fn new(repo: Arc) -> Self { - Self { repo } + pub fn new(repo: Arc, pref_repo: Arc) -> Self { + Self { repo, pref_repo } } /// Get current system settings, falling back to defaults if not yet persisted. @@ -30,15 +60,48 @@ impl SettingsService { .await .map_err(|e| SystemError::Internal(format!("Failed to get settings: {e}")))?; - Ok( - row.map_or_else(SystemSettingsResponse::default, |s| SystemSettingsResponse { - language: s.language, - notification_enabled: s.notification_enabled, - cron_notification_enabled: s.cron_notification_enabled, - command_queue_enabled: s.command_queue_enabled, - save_upload_to_workspace: s.save_upload_to_workspace, - }), - ) + let mut settings = row.map_or_else(SystemSettingsResponse::default, |s| SystemSettingsResponse { + language: s.language, + notification_enabled: s.notification_enabled, + cron_notification_enabled: s.cron_notification_enabled, + command_queue_enabled: s.command_queue_enabled, + save_upload_to_workspace: s.save_upload_to_workspace, + }); + + // Preferences are the truth; the columns read above are the fallback + // for anything a preference does not (yet) cover. + let prefs = self.get_settings_preferences(user_id).await?; + for (key, raw) in &prefs { + match key.as_str() { + LANGUAGE_PREF_KEY => { + if let Some(language) = parse_language_preference(raw) { + settings.language = language; + } + } + NOTIFICATION_ENABLED_PREF_KEY => { + if let Some(enabled) = parse_bool_preference(key, raw) { + settings.notification_enabled = enabled; + } + } + CRON_NOTIFICATION_ENABLED_PREF_KEY => { + if let Some(enabled) = parse_bool_preference(key, raw) { + settings.cron_notification_enabled = enabled; + } + } + COMMAND_QUEUE_ENABLED_PREF_KEY => { + if let Some(enabled) = parse_bool_preference(key, raw) { + settings.command_queue_enabled = enabled; + } + } + SAVE_UPLOAD_TO_WORKSPACE_PREF_KEY => { + if let Some(enabled) = parse_bool_preference(key, raw) { + settings.save_upload_to_workspace = enabled; + } + } + _ => {} + } + } + Ok(settings) } /// Partially update system settings. Only fields present in the request are changed. @@ -62,8 +125,28 @@ impl SettingsService { let command_queue_enabled = req.command_queue_enabled.unwrap_or(current.command_queue_enabled); let save_upload_to_workspace = req.save_upload_to_workspace.unwrap_or(current.save_upload_to_workspace); - let row = self - .repo + // The truth lives in client_preferences; the column write below only + // keeps the legacy fallback convergent for pre-migration readers. + let language_value = serde_json::Value::String(language.clone()).to_string(); + let entries = [ + (LANGUAGE_PREF_KEY, language_value.as_str()), + (NOTIFICATION_ENABLED_PREF_KEY, bool_pref_value(notification_enabled)), + ( + CRON_NOTIFICATION_ENABLED_PREF_KEY, + bool_pref_value(cron_notification_enabled), + ), + (COMMAND_QUEUE_ENABLED_PREF_KEY, bool_pref_value(command_queue_enabled)), + ( + SAVE_UPLOAD_TO_WORKSPACE_PREF_KEY, + bool_pref_value(save_upload_to_workspace), + ), + ]; + self.pref_repo + .upsert_batch(user_id, &entries) + .await + .map_err(|e| SystemError::Internal(format!("Failed to update settings preferences: {e}")))?; + + self.repo .upsert_settings( user_id, &language, @@ -76,13 +159,69 @@ impl SettingsService { .map_err(|e| SystemError::Internal(format!("Failed to update settings: {e}")))?; Ok(SystemSettingsResponse { - language: row.language, - notification_enabled: row.notification_enabled, - cron_notification_enabled: row.cron_notification_enabled, - command_queue_enabled: row.command_queue_enabled, - save_upload_to_workspace: row.save_upload_to_workspace, + language, + notification_enabled, + cron_notification_enabled, + command_queue_enabled, + save_upload_to_workspace, }) } + + /// Reads this service's preference keys as raw stored values, keyed by + /// preference key. Missing keys are simply absent. + async fn get_settings_preferences(&self, user_id: &str) -> Result, SystemError> { + let rows = self + .pref_repo + .get_by_keys(user_id, SETTINGS_PREF_KEYS) + .await + .map_err(|e| SystemError::Internal(format!("Failed to get settings preferences: {e}")))?; + Ok(rows.into_iter().map(|row| (row.key, row.value)).collect()) + } +} + +/// Parse a stored language preference, tolerating both JSON-encoded and raw +/// string storage. Non-string or empty values are ignored (the legacy column +/// then serves as the fallback). +fn parse_language_preference(raw: &str) -> Option { + let value = match serde_json::from_str::(raw) { + Ok(serde_json::Value::String(s)) => s, + Ok(_) => { + warn!( + key = LANGUAGE_PREF_KEY, + "Ignoring non-string stored language preference" + ); + return None; + } + // Raw (non-JSON) storage from older writers. + Err(_) => raw.to_owned(), + }; + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_owned()) +} + +/// Parse a stored boolean switch preference. JSON booleans are the canonical +/// encoding; bare `true`/`false` text from older writers is tolerated. Anything +/// else is ignored with a warning so the legacy column stays the fallback. +fn parse_bool_preference(key: &str, raw: &str) -> Option { + if let Ok(serde_json::Value::Bool(enabled)) = serde_json::from_str::(raw) { + return Some(enabled); + } + match raw.trim() { + "true" => Some(true), + "false" => Some(false), + _ => { + warn!(key, "Ignoring non-boolean stored settings preference"); + None + } + } +} + +/// Canonical stored encoding for a boolean switch preference. +fn bool_pref_value(enabled: bool) -> &'static str { + if enabled { "true" } else { "false" } } fn validate_language(lang: &str) -> Result<(), SystemError> { @@ -98,9 +237,13 @@ mod tests { use super::*; const TEST_USER_ID: &str = "user-1"; - use aionui_db::{SqliteSettingsRepository, init_database_memory}; + use aionui_db::{SqliteClientPreferenceRepository, SqliteSettingsRepository, init_database_memory}; async fn setup() -> SettingsService { + setup_with_prefs().await.0 + } + + async fn setup_with_prefs() -> (SettingsService, Arc) { let db = init_database_memory().await.unwrap(); sqlx::query( "INSERT INTO users (id, user_type, username, password_hash, status, session_generation, created_at, updated_at) \ @@ -112,9 +255,10 @@ mod tests { .await .unwrap(); let repo = Arc::new(SqliteSettingsRepository::new(db.pool().clone())); + let pref_repo = Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone())); // Leak the db handle so the pool stays alive for the test std::mem::forget(db); - SettingsService::new(repo) + (SettingsService::new(repo, pref_repo.clone()), pref_repo) } #[test] @@ -187,6 +331,235 @@ mod tests { assert!(matches!(err, SystemError::BadRequest(_))); } + #[tokio::test] + async fn language_preference_wins_over_legacy_column() { + let (svc, _prefs) = setup_with_prefs().await; + // Legacy column says zh-CN… + svc.repo + .upsert_settings(TEST_USER_ID, "zh-CN", true, false, false, false) + .await + .unwrap(); + // …but the preference (single truth) says ja-JP. + svc.pref_repo + .upsert_batch(TEST_USER_ID, &[(LANGUAGE_PREF_KEY, "\"ja-JP\"")]) + .await + .unwrap(); + + let settings = svc.get_settings(TEST_USER_ID).await.unwrap(); + assert_eq!(settings.language, "ja-JP"); + } + + #[tokio::test] + async fn language_falls_back_to_legacy_column_without_preference() { + let (svc, _prefs) = setup_with_prefs().await; + svc.repo + .upsert_settings(TEST_USER_ID, "zh-TW", true, false, false, false) + .await + .unwrap(); + + let settings = svc.get_settings(TEST_USER_ID).await.unwrap(); + assert_eq!(settings.language, "zh-TW"); + } + + #[tokio::test] + async fn update_language_writes_the_preference_truth() { + let (svc, prefs) = setup_with_prefs().await; + let req = UpdateSettingsRequest { + language: Some("ko-KR".into()), + ..Default::default() + }; + let result = svc.update_settings(TEST_USER_ID, req).await.unwrap(); + assert_eq!(result.language, "ko-KR"); + + // The preference row is the stored truth (JSON-encoded string). + let rows = prefs.get_by_keys(TEST_USER_ID, &[LANGUAGE_PREF_KEY]).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].value, "\"ko-KR\""); + // And reads agree with it. + assert_eq!(svc.get_settings(TEST_USER_ID).await.unwrap().language, "ko-KR"); + } + + #[tokio::test] + async fn raw_string_preference_storage_is_tolerated() { + let (svc, prefs) = setup_with_prefs().await; + // Older writers stored the raw string without JSON encoding. + prefs + .upsert_batch(TEST_USER_ID, &[(LANGUAGE_PREF_KEY, "fr-FR")]) + .await + .unwrap(); + + assert_eq!(svc.get_settings(TEST_USER_ID).await.unwrap().language, "fr-FR"); + } + + #[tokio::test] + async fn non_string_language_preference_is_ignored() { + let (svc, prefs) = setup_with_prefs().await; + svc.repo + .upsert_settings(TEST_USER_ID, "zh-CN", true, false, false, false) + .await + .unwrap(); + prefs + .upsert_batch(TEST_USER_ID, &[(LANGUAGE_PREF_KEY, "123")]) + .await + .unwrap(); + + // Falls back to the legacy column. + assert_eq!(svc.get_settings(TEST_USER_ID).await.unwrap().language, "zh-CN"); + } + + #[tokio::test] + async fn switch_preferences_win_over_legacy_columns() { + let (svc, prefs) = setup_with_prefs().await; + // Legacy columns say: notification on, cron off, queue off, save off… + svc.repo + .upsert_settings(TEST_USER_ID, "en-US", true, false, false, false) + .await + .unwrap(); + // …but the preferences (single truth) say the exact opposite. + prefs + .upsert_batch( + TEST_USER_ID, + &[ + (NOTIFICATION_ENABLED_PREF_KEY, "false"), + (CRON_NOTIFICATION_ENABLED_PREF_KEY, "true"), + (COMMAND_QUEUE_ENABLED_PREF_KEY, "true"), + (SAVE_UPLOAD_TO_WORKSPACE_PREF_KEY, "true"), + ], + ) + .await + .unwrap(); + + let settings = svc.get_settings(TEST_USER_ID).await.unwrap(); + assert!(!settings.notification_enabled); + assert!(settings.cron_notification_enabled); + assert!(settings.command_queue_enabled); + assert!(settings.save_upload_to_workspace); + } + + #[tokio::test] + async fn switches_fall_back_to_legacy_columns_without_preferences() { + let (svc, _prefs) = setup_with_prefs().await; + svc.repo + .upsert_settings(TEST_USER_ID, "en-US", false, true, true, true) + .await + .unwrap(); + + let settings = svc.get_settings(TEST_USER_ID).await.unwrap(); + assert!(!settings.notification_enabled); + assert!(settings.cron_notification_enabled); + assert!(settings.command_queue_enabled); + assert!(settings.save_upload_to_workspace); + } + + #[tokio::test] + async fn a_single_switch_preference_overlays_only_itself() { + let (svc, prefs) = setup_with_prefs().await; + svc.repo + .upsert_settings(TEST_USER_ID, "en-US", true, true, false, false) + .await + .unwrap(); + prefs + .upsert_batch(TEST_USER_ID, &[(CRON_NOTIFICATION_ENABLED_PREF_KEY, "false")]) + .await + .unwrap(); + + let settings = svc.get_settings(TEST_USER_ID).await.unwrap(); + assert!(!settings.cron_notification_enabled, "pref wins for its own key"); + assert!(settings.notification_enabled, "other switches keep the column value"); + assert!(!settings.command_queue_enabled); + assert!(!settings.save_upload_to_workspace); + } + + #[tokio::test] + async fn non_boolean_switch_preference_is_ignored() { + let (svc, prefs) = setup_with_prefs().await; + svc.repo + .upsert_settings(TEST_USER_ID, "en-US", false, false, false, false) + .await + .unwrap(); + prefs + .upsert_batch(TEST_USER_ID, &[(NOTIFICATION_ENABLED_PREF_KEY, "\"yes\"")]) + .await + .unwrap(); + + // Falls back to the legacy column. + assert!(!svc.get_settings(TEST_USER_ID).await.unwrap().notification_enabled); + } + + #[tokio::test] + async fn update_writes_every_switch_as_a_preference() { + let (svc, prefs) = setup_with_prefs().await; + let result = svc + .update_settings( + TEST_USER_ID, + UpdateSettingsRequest { + notification_enabled: Some(false), + command_queue_enabled: Some(true), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!(!result.notification_enabled); + assert!(result.command_queue_enabled); + + // All five keys are written, including the ones the request omitted — + // preferences must describe the full effective state. + let stored: std::collections::BTreeMap = prefs + .get_by_keys(TEST_USER_ID, SETTINGS_PREF_KEYS) + .await + .unwrap() + .into_iter() + .map(|row| (row.key, row.value)) + .collect(); + assert_eq!(stored.len(), SETTINGS_PREF_KEYS.len()); + assert_eq!(stored[NOTIFICATION_ENABLED_PREF_KEY], "false"); + assert_eq!(stored[COMMAND_QUEUE_ENABLED_PREF_KEY], "true"); + assert_eq!(stored[CRON_NOTIFICATION_ENABLED_PREF_KEY], "false"); + assert_eq!(stored[SAVE_UPLOAD_TO_WORKSPACE_PREF_KEY], "false"); + assert_eq!(stored[LANGUAGE_PREF_KEY], "\"en-US\""); + + // And reads agree with the stored truth. + let settings = svc.get_settings(TEST_USER_ID).await.unwrap(); + assert!(!settings.notification_enabled); + assert!(settings.command_queue_enabled); + } + + #[tokio::test] + async fn switch_preference_survives_an_unrelated_update() { + let (svc, prefs) = setup_with_prefs().await; + prefs + .upsert_batch(TEST_USER_ID, &[(SAVE_UPLOAD_TO_WORKSPACE_PREF_KEY, "true")]) + .await + .unwrap(); + + // Updating only the language must carry the switch's effective value + // forward, not reset it to the column default. + let result = svc + .update_settings( + TEST_USER_ID, + UpdateSettingsRequest { + language: Some("ja-JP".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!(result.save_upload_to_workspace); + assert!(svc.get_settings(TEST_USER_ID).await.unwrap().save_upload_to_workspace); + } + + #[test] + fn parse_bool_preference_accepts_json_and_raw_booleans() { + assert_eq!(parse_bool_preference("k", "true"), Some(true)); + assert_eq!(parse_bool_preference("k", "false"), Some(false)); + assert_eq!(parse_bool_preference("k", " true "), Some(true)); + assert_eq!(parse_bool_preference("k", "\"true\""), None); + assert_eq!(parse_bool_preference("k", "1"), None); + assert_eq!(parse_bool_preference("k", "yes"), None); + assert_eq!(parse_bool_preference("k", ""), None); + } + #[tokio::test] async fn update_then_get_reflects_changes() { let svc = setup().await; diff --git a/crates/aionui-system/tests/feedback_diagnostics_routes.rs b/crates/aionui-system/tests/feedback_diagnostics_routes.rs index bc1d915fe..fd06d1645 100644 --- a/crates/aionui-system/tests/feedback_diagnostics_routes.rs +++ b/crates/aionui-system/tests/feedback_diagnostics_routes.rs @@ -23,7 +23,10 @@ fn build_state(db: &aionui_db::Database) -> SystemRouterState { let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone())); let http_client = reqwest::Client::new(); SystemRouterState { - settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))), + settings_service: SettingsService::new( + Arc::new(SqliteSettingsRepository::new(db.pool().clone())), + Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone())), + ), client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))), provider_service: ProviderService::new(provider_repo.clone(), TEST_ENCRYPTION_KEY), model_fetch_service: ModelFetchService::new(provider_repo, TEST_ENCRYPTION_KEY, http_client.clone()), diff --git a/crates/aionui-system/tests/model_fetch_routes.rs b/crates/aionui-system/tests/model_fetch_routes.rs index b99291058..68db8396f 100644 --- a/crates/aionui-system/tests/model_fetch_routes.rs +++ b/crates/aionui-system/tests/model_fetch_routes.rs @@ -36,7 +36,10 @@ fn build_state(db: &aionui_db::Database) -> SystemRouterState { let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone())); let http_client = reqwest::Client::new(); SystemRouterState { - settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))), + settings_service: SettingsService::new( + Arc::new(SqliteSettingsRepository::new(db.pool().clone())), + Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone())), + ), client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))), provider_service: ProviderService::new(provider_repo.clone(), TEST_KEY), model_fetch_service: ModelFetchService::new(provider_repo, TEST_KEY, http_client.clone()), diff --git a/crates/aionui-system/tests/protocol_detection_routes.rs b/crates/aionui-system/tests/protocol_detection_routes.rs index 3ea71c194..485c81c2f 100644 --- a/crates/aionui-system/tests/protocol_detection_routes.rs +++ b/crates/aionui-system/tests/protocol_detection_routes.rs @@ -33,7 +33,10 @@ fn build_state(db: &aionui_db::Database) -> SystemRouterState { let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone())); let http_client = reqwest::Client::new(); SystemRouterState { - settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))), + settings_service: SettingsService::new( + Arc::new(SqliteSettingsRepository::new(db.pool().clone())), + Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone())), + ), client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))), provider_service: ProviderService::new(provider_repo.clone(), TEST_KEY), model_fetch_service: ModelFetchService::new(provider_repo, TEST_KEY, http_client.clone()), diff --git a/crates/aionui-system/tests/provider_routes.rs b/crates/aionui-system/tests/provider_routes.rs index 4e233346f..6c97b404b 100644 --- a/crates/aionui-system/tests/provider_routes.rs +++ b/crates/aionui-system/tests/provider_routes.rs @@ -35,7 +35,10 @@ fn build_state(db: &aionui_db::Database) -> SystemRouterState { let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone())); let http_client = reqwest::Client::new(); SystemRouterState { - settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))), + settings_service: SettingsService::new( + Arc::new(SqliteSettingsRepository::new(db.pool().clone())), + Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone())), + ), client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))), provider_service: ProviderService::new(provider_repo.clone(), TEST_ENCRYPTION_KEY), model_fetch_service: ModelFetchService::new(provider_repo, TEST_ENCRYPTION_KEY, http_client.clone()), diff --git a/crates/aionui-system/tests/settings_routes.rs b/crates/aionui-system/tests/settings_routes.rs index 49a4b3d0d..76b84cfb5 100644 --- a/crates/aionui-system/tests/settings_routes.rs +++ b/crates/aionui-system/tests/settings_routes.rs @@ -34,7 +34,10 @@ fn build_state(db: &aionui_db::Database) -> SystemRouterState { let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone())); let http_client = reqwest::Client::new(); SystemRouterState { - settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))), + settings_service: SettingsService::new( + Arc::new(SqliteSettingsRepository::new(db.pool().clone())), + Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone())), + ), client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))), provider_service: ProviderService::new(provider_repo.clone(), TEST_ENCRYPTION_KEY), model_fetch_service: ModelFetchService::new(provider_repo, TEST_ENCRYPTION_KEY, http_client.clone()), @@ -264,6 +267,52 @@ async fn settings_are_scoped_by_current_user() { assert_eq!(other_json["data"]["notification_enabled"], true); } +#[tokio::test] +async fn language_is_one_truth_across_settings_and_client_prefs() { + let (app, db) = setup().await; + + // PATCH /api/settings writes the language and a boolean switch… + let resp = app + .oneshot(json_request( + "PATCH", + "/api/settings", + serde_json::json!({"language": "ko-KR", "notification_enabled": false}), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // …and /api/settings/client sees the exact same stored truth. + let client_app = settings_routes(build_state(&db)); + let resp = client_app + .oneshot(get_request( + "/api/settings/client?keys=language,system.notificationEnabled", + )) + .await + .unwrap(); + let json = body_json(resp).await; + assert_eq!(json["data"]["language"], "ko-KR"); + assert_eq!(json["data"]["system.notificationEnabled"], false); + + // Writing via client prefs flips what /api/settings reports (pref wins). + let client_app = settings_routes(build_state(&db)); + let resp = client_app + .oneshot(json_request( + "PUT", + "/api/settings/client", + serde_json::json!({"language": "pt-BR", "system.notificationEnabled": true}), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let settings_app = settings_routes(build_state(&db)); + let resp = settings_app.oneshot(get_request("/api/settings")).await.unwrap(); + let json = body_json(resp).await; + assert_eq!(json["data"]["language"], "pt-BR"); + assert_eq!(json["data"]["notification_enabled"], true); +} + // =========================================================================== // Client Preferences (GET/PUT /api/settings/client) // =========================================================================== diff --git a/crates/aionui-system/tests/system_info_routes.rs b/crates/aionui-system/tests/system_info_routes.rs index fb3f52526..a65377545 100644 --- a/crates/aionui-system/tests/system_info_routes.rs +++ b/crates/aionui-system/tests/system_info_routes.rs @@ -36,7 +36,10 @@ fn build_state(db: &aionui_db::Database, version_check_service: VersionCheckServ let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone())); let http_client = reqwest::Client::new(); SystemRouterState { - settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))), + settings_service: SettingsService::new( + Arc::new(SqliteSettingsRepository::new(db.pool().clone())), + Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone())), + ), client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))), provider_service: ProviderService::new(provider_repo.clone(), TEST_KEY), model_fetch_service: ModelFetchService::new(provider_repo, TEST_KEY, http_client.clone()),